From 7406d30d4a86fe23fcb4402bdc7605140b664f71 Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Wed, 29 Jul 2026 10:23:54 +0200 Subject: [PATCH] Tree Generator als zweite Extension im selben Repo Ein Extension-Repo kann mehrere Addons ausliefern (server-generate listet jedes Zip in dist/) -> die bereits eingetragene Blender-URL liefert jetzt Rock UND Tree, ein 'Check for Updates' aktualisiert beide. - stylized_tree_generator.py: Panel 'Tree Gen', Presets baum/palme/busch/kaktus, Wachstums-Stufen (gleicher Seed = dieselbe Baum-Identitaet), Modifier-Apply - Node-Kern 1:1 aus EcoGame tools/blender_tree_gen.py generiert (keine Abweichung) - build.ps1 baut jetzt beide Extensions - Headless getestet: alle 4 Presets, Stufen aufsteigend (504/744/1236/1872), Apply ok Co-Authored-By: Claude Opus 4.8 --- README.md | 32 ++ blender_manifest_tree.toml | 22 + build.ps1 | 49 +- dist/index.json | 29 +- dist/stylized_rock_generator-2.2.0.zip | Bin 14107 -> 14690 bytes dist/stylized_tree_generator-1.0.0.zip | Bin 0 -> 9292 bytes stylized_rock_generator.py | 156 ++++-- stylized_tree_generator.py | 704 +++++++++++++++++++++++++ 8 files changed, 920 insertions(+), 72 deletions(-) create mode 100644 blender_manifest_tree.toml create mode 100644 dist/stylized_tree_generator-1.0.0.zip create mode 100644 stylized_tree_generator.py diff --git a/README.md b/README.md index 8a0de5a..06418d0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ +# Stylized Asset Generators (Blender Addons) + +> Dieses Repo liefert **zwei** Extensions ueber **eine** URL: +> +> | Addon | Panel | Zweck | +> |---|---|---| +> | **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 | +> +> Ein Blender-Extension-Repo kann mehrere Extensions ausliefern — `server-generate` +> listet jedes Zip in `dist/`. Ein „Check for Updates" aktualisiert also beide. + +--- + +## Stylized Tree Generator + +Parametrischer **Geaest**-Generator (Stamm + Aeste + Sub-Aeste) als Geometry-Nodes- +Gruppe. Blaetter/Krone macht man selbst — das Addon liefert die Struktur. + +- **Presets:** `baum`, `palme`, `busch`, `kaktus` +- **Wachstums-Stufen:** Haken setzen + Anzahl waehlen → Setzling bis ausgewachsen, + **gleicher Seed = dieselbe Baum-Identitaet** (fuer Wachstums-Systeme im Spiel). +- **Live bearbeiten:** Nach dem Erzeugen liegen alle Regler am Modifier `GN_Tree` — + im Viewport ziehen, der Baum aktualisiert sich sofort. +- **Export:** „Modifier anwenden" friert das Ergebnis als normales Mesh ein. + +⚠️ **Bekannte Grenze:** Das `kaktus`-Preset ist noch nicht ueberzeugend — die Arme +spreizen im V, statt nach dem Abgang senkrecht hochzulaufen (die Droop-Naeherung +kann nur einen gleichmaessigen Bogen, keinen Ellbogen). Als Platzhalter brauchbar. + +--- + # Stylized Rock Generator (Blender Addon) Batch-Generator fuer stylized Rocks in Blender, ausgelegt auf den Export nach diff --git a/blender_manifest_tree.toml b/blender_manifest_tree.toml new file mode 100644 index 0000000..cf5e935 --- /dev/null +++ b/blender_manifest_tree.toml @@ -0,0 +1,22 @@ +schema_version = "1.0.0" + +id = "stylized_tree_generator" +version = "1.0.0" +name = "Stylized Tree Generator" +tagline = "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen" +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 55744c0..ccddb37 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,11 +1,15 @@ -# Baut die Extension und aktualisiert das Repo-Listing (index.json) in dist/. +# Baut ALLE Extensions dieses Repos und aktualisiert das Repo-Listing (index.json). +# +# Ein Blender-Extension-Repo kann MEHRERE Extensions ausliefern: "server-generate" +# scannt den dist/-Ordner und listet jedes gefundene Zip. Dadurch liefert EINE +# URL in Blender sowohl den Rock- als auch den Tree-Generator. # # Aufruf: # .\build.ps1 # nutzt "blender" aus dem PATH # .\build.ps1 -Blender "E:\...\blender.exe" # # Update-Workflow: -# 1. version in blender_manifest.toml erhoehen (und bl_info/version in der .py) +# 1. version im jeweiligen Manifest erhoehen (und bl_info in der .py) # 2. .\build.ps1 # 3. git add -A; git commit -m "vX.Y.Z"; git push # 4. In Blender: Preferences -> Get Extensions -> Repo -> "Check for Updates" @@ -16,25 +20,38 @@ param( $ErrorActionPreference = "Stop" $root = $PSScriptRoot -$stage = Join-Path $root "_extsrc" -$dist = Join-Path $root "dist" +$dist = Join-Path $root "dist" +New-Item -ItemType Directory -Force -Path $dist | Out-Null -if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } -New-Item -ItemType Directory -Force -Path $stage | Out-Null -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" } +) -# Staging: Manifest + Addon-Code als __init__.py (Extension = Package) -Copy-Item (Join-Path $root "blender_manifest.toml") (Join-Path $stage "blender_manifest.toml") -Copy-Item (Join-Path $root "stylized_rock_generator.py") (Join-Path $stage "__init__.py") +# Alte Zips weg, damit geloeschte Versionen nicht im Listing haengen bleiben. +Get-ChildItem $dist -Filter *.zip -ErrorAction SilentlyContinue | Remove-Item -Force -Write-Host "== extension build ==" -ForegroundColor Cyan -& $Blender --command extension build --source-dir $stage --output-dir $dist -if ($LASTEXITCODE -ne 0) { throw "extension build fehlgeschlagen (Exit $LASTEXITCODE)" } +foreach ($ext in $extensions) { + $stage = Join-Path $root ("_extsrc_" + $ext.name) + if (Test-Path $stage) { Remove-Item -Recurse -Force $stage } + New-Item -ItemType Directory -Force -Path $stage | Out-Null + + # Extension = Package: Manifest heisst IMMER blender_manifest.toml, + # der Addon-Code IMMER __init__.py. + Copy-Item (Join-Path $root $ext.manifest) (Join-Path $stage "blender_manifest.toml") + Copy-Item (Join-Path $root $ext.source) (Join-Path $stage "__init__.py") + + Write-Host ("== build " + $ext.name + " ==") -ForegroundColor Cyan + & $Blender --command extension build --source-dir $stage --output-dir $dist + if ($LASTEXITCODE -ne 0) { throw ("build fehlgeschlagen: " + $ext.name) } + + Remove-Item -Recurse -Force $stage +} Write-Host "== server-generate (index.json) ==" -ForegroundColor Cyan & $Blender --command extension server-generate --repo-dir $dist -if ($LASTEXITCODE -ne 0) { throw "server-generate fehlgeschlagen (Exit $LASTEXITCODE)" } +if ($LASTEXITCODE -ne 0) { throw "server-generate fehlgeschlagen" } -Remove-Item -Recurse -Force $stage -Write-Host "Fertig. dist/ enthaelt Zip + index.json." -ForegroundColor Green +Write-Host "Fertig." -ForegroundColor Green Get-ChildItem $dist | Select-Object Name, Length | Format-Table -AutoSize diff --git a/dist/index.json b/dist/index.json index 966b395..1e64493 100644 --- a/dist/index.json +++ b/dist/index.json @@ -24,8 +24,33 @@ "Modeling" ], "archive_url": "./stylized_rock_generator-2.2.0.zip", - "archive_size": 14107, - "archive_hash": "sha256:4a4a83e9f6955f0deb0fe56dce59d8e540424df21982639e457db1b3533d9cf5" + "archive_size": 14690, + "archive_hash": "sha256:b9f83df54b5ac5fa5824bd6753dccac93175e03f19b04067a0865f362cbb022e" + }, + { + "schema_version": "1.0.0", + "id": "stylized_tree_generator", + "name": "Stylized Tree Generator", + "tagline": "Parametrische Baeume, Palmen, Bueschen mit Wachstums-Stufen", + "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_tree_generator-1.0.0.zip", + "archive_size": 9292, + "archive_hash": "sha256:23693ed0476102b52d76e67d1d711ce6c55925b47c9323a06d535e51a2508c01" } ] } \ No newline at end of file diff --git a/dist/stylized_rock_generator-2.2.0.zip b/dist/stylized_rock_generator-2.2.0.zip index e21f1274c846db52ca35f15e0975200c4127bf06..baecb632f5465f0155735d5d9d2b8d5af5b771fa 100644 GIT binary patch delta 14319 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 delta 13704 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 tilet in U und V ohne Naht. - Liefert (mat, sockets) mit den fertigen Ausgangs-Sockets fuer die Bake-Passes. + projiziert und 4D gesampelt -> tilet in U und V ohne Naht. Voronoi liefert + hand-painted Zellen mit dunklen Outlines; base_color/cell_scale steuern Ton + und Zellgroesse. Liefert (mat, sockets) fuer die Bake-Passes. """ + br, bg, bb = base_color[0], base_color[1], base_color[2] + + def _tone(factor): + return (min(br * factor, 1.0), min(bg * factor, 1.0), min(bb * factor, 1.0), 1.0) mat = bpy.data.materials.new("__RockBakeMat") mat.use_nodes = True nt = mat.node_tree @@ -974,62 +988,91 @@ def _build_bake_material(): links.new(su, comb.inputs[1]) links.new(cv, comb.inputs[2]) - def _torus_noise(scale, detail): - n = nodes.new("ShaderNodeTexNoise") - n.noise_dimensions = '4D' - n.inputs["Scale"].default_value = scale - n.inputs["Detail"].default_value = detail - links.new(comb.outputs[0], n.inputs["Vector"]) - links.new(sv, n.inputs["W"]) - return n + # Domain-Warp: verzerrt die Zellen leicht organisch (tileable, da gleiche + # Torus-Coords -> ueber die Naht hinweg stetig). + warp_noise = nodes.new("ShaderNodeTexNoise") + warp_noise.noise_dimensions = '4D' + warp_noise.inputs["Scale"].default_value = 3.0 + warp_noise.inputs["Detail"].default_value = 2.0 + links.new(comb.outputs[0], warp_noise.inputs["Vector"]) + links.new(sv, warp_noise.inputs["W"]) + warp_scale = nodes.new("ShaderNodeVectorMath"); warp_scale.operation = 'SCALE' + links.new(warp_noise.outputs["Color"], warp_scale.inputs[0]) + warp_scale.inputs[3].default_value = 0.15 + comb_w = nodes.new("ShaderNodeVectorMath"); comb_w.operation = 'ADD' + links.new(comb.outputs[0], comb_w.inputs[0]) + links.new(warp_scale.outputs[0], comb_w.inputs[1]) - noise_base = _torus_noise(2.5, 6.0) # grobe Formvariation - noise_cavity = _torus_noise(5.0, 6.0) # feinere Risse/Cavity - noise_detail = _torus_noise(9.0, 8.0) # Mikro-Detail fuer die Normal Map + # Zwei Voronoi-Nodes mit identischen Coords: Rand-Distanz + Per-Zell-Farbe. + vor_edge = nodes.new("ShaderNodeTexVoronoi") + vor_edge.voronoi_dimensions = '4D' + vor_edge.feature = 'DISTANCE_TO_EDGE' + vor_edge.inputs["Scale"].default_value = cell_scale + links.new(comb_w.outputs[0], vor_edge.inputs["Vector"]) + links.new(sv, vor_edge.inputs["W"]) - # Cavity/AO: Kontrast per MapRange + vor_cell = nodes.new("ShaderNodeTexVoronoi") + vor_cell.voronoi_dimensions = '4D' + vor_cell.feature = 'F1' + vor_cell.inputs["Scale"].default_value = cell_scale + links.new(comb_w.outputs[0], vor_cell.inputs["Vector"]) + links.new(sv, vor_cell.inputs["W"]) + + # cell_fac: 0 an den Zellraendern (dunkle Outlines), 1 in der Zellmitte. + # Breiter Bereich -> jede Zelle bekommt einen weichen Woelbungs-Gradient. + cell_fac = nodes.new("ShaderNodeMapRange") + links.new(vor_edge.outputs["Distance"], cell_fac.inputs["Value"]) + cell_fac.inputs["From Min"].default_value = 0.0 + cell_fac.inputs["From Max"].default_value = 0.32 + cell_fac.inputs["To Min"].default_value = 0.0 + cell_fac.inputs["To Max"].default_value = 1.0 + cell_fac.clamp = True + + # Per-Zell-Helligkeit (leichte Variation Zelle zu Zelle) + cell_bw = nodes.new("ShaderNodeRGBToBW") + links.new(vor_cell.outputs["Color"], cell_bw.inputs[0]) + cell_var = nodes.new("ShaderNodeMapRange") + links.new(cell_bw.outputs[0], cell_var.inputs["Value"]) + cell_var.inputs["To Min"].default_value = 0.78 + cell_var.inputs["To Max"].default_value = 1.08 + cell_var.clamp = True + + combined = nodes.new("ShaderNodeMath"); combined.operation = 'MULTIPLY' + links.new(cell_fac.outputs[0], combined.inputs[0]) + links.new(cell_var.outputs[0], combined.inputs[1]) + + # BaseColor: dunkle Outline -> Zell-Ton -> Highlight (hand-painted Look), + # alle Toene aus base_color abgeleitet. + ramp = nodes.new("ShaderNodeValToRGB") + cr = ramp.color_ramp + cr.elements[0].position = 0.0 + cr.elements[0].color = _tone(0.12) # Riss / Outline + cr.elements[1].position = 0.85 + cr.elements[1].color = _tone(1.22) # Zell-Highlight + mid = cr.elements.new(0.32) + mid.color = _tone(0.72) # Zell-Grundton + links.new(combined.outputs[0], ramp.inputs["Fac"]) + + # Roughness: an den Rissen etwas rauer + rough = nodes.new("ShaderNodeMapRange") + links.new(cell_fac.outputs[0], rough.inputs["Value"]) + rough.inputs["To Min"].default_value = 0.95 + rough.inputs["To Max"].default_value = 0.72 + rough.clamp = True + + # AO: Zellraender abgedunkelt ao = nodes.new("ShaderNodeMapRange") - links.new(noise_cavity.outputs["Fac"], ao.inputs["Value"]) - ao.inputs["From Min"].default_value = 0.3 - ao.inputs["From Max"].default_value = 0.7 + links.new(cell_fac.outputs[0], ao.inputs["Value"]) ao.inputs["To Min"].default_value = 0.35 ao.inputs["To Max"].default_value = 1.0 ao.clamp = True - # BaseColor: kombinierter Fac (Form * Cavity) durch ColorRamp - comb_fac = nodes.new("ShaderNodeMath"); comb_fac.operation = 'MULTIPLY' - links.new(noise_base.outputs["Fac"], comb_fac.inputs[0]) - links.new(ao.outputs[0], comb_fac.inputs[1]) - ramp = nodes.new("ShaderNodeValToRGB") - ramp.color_ramp.elements[0].position = 0.25 - ramp.color_ramp.elements[0].color = (0.09, 0.09, 0.10, 1.0) - ramp.color_ramp.elements[1].position = 0.85 - ramp.color_ramp.elements[1].color = (0.46, 0.42, 0.37, 1.0) - links.new(comb_fac.outputs[0], ramp.inputs["Fac"]) - - # Roughness: rauer in dunklen Bereichen - rough = nodes.new("ShaderNodeMapRange") - links.new(noise_base.outputs["Fac"], rough.inputs["Value"]) - rough.inputs["To Min"].default_value = 0.9 - rough.inputs["To Max"].default_value = 0.6 - rough.clamp = True - - # Bump -> Normal (fuer den Normal-Bake). Height = grobe Form + Mikro-Detail, - # damit die Normal Map sichtbare Struktur bekommt (nicht flach). - height_mix = nodes.new("ShaderNodeMath"); height_mix.operation = 'ADD' - height_mix.inputs[1].default_value = 0.0 - hb = nodes.new("ShaderNodeMath"); hb.operation = 'MULTIPLY' - hb.inputs[1].default_value = 0.6 - links.new(noise_base.outputs["Fac"], hb.inputs[0]) - hd = nodes.new("ShaderNodeMath"); hd.operation = 'MULTIPLY' - hd.inputs[1].default_value = 0.4 - links.new(noise_detail.outputs["Fac"], hd.inputs[0]) - links.new(hb.outputs[0], height_mix.inputs[0]) - links.new(hd.outputs[0], height_mix.inputs[1]) + # Bump: Risse werden zu Rillen (cell_fac niedrig am Rand -> vertieft) bump = nodes.new("ShaderNodeBump") - bump.inputs["Strength"].default_value = 0.9 - bump.inputs["Distance"].default_value = 0.5 - links.new(height_mix.outputs[0], bump.inputs["Height"]) + bump.inputs["Strength"].default_value = 0.7 + bump.inputs["Distance"].default_value = 0.4 + links.new(cell_fac.outputs[0], bump.inputs["Height"]) + links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) links.new(rough.outputs[0], bsdf.inputs["Roughness"]) links.new(bump.outputs["Normal"], bsdf.inputs["Normal"]) @@ -1092,7 +1135,10 @@ class OBJECT_OT_bake_rock_textures(Operator): bpy.ops.mesh.primitive_plane_add(size=2.0) plane = context.active_object plane.name = "__RockBakePlane" - mat, sk = _build_bake_material() + mat, sk = _build_bake_material( + base_color=tuple(settings.bake_base_color), + cell_scale=settings.bake_cell_scale, + ) plane.data.materials.clear() plane.data.materials.append(mat) @@ -1346,6 +1392,8 @@ class VIEW3D_PT_rock_generator(Panel): 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") row = box.row(align=True) row.prop(settings, "bake_color", toggle=True) row.prop(settings, "bake_normal", toggle=True) diff --git a/stylized_tree_generator.py b/stylized_tree_generator.py new file mode 100644 index 0000000..226775a --- /dev/null +++ b/stylized_tree_generator.py @@ -0,0 +1,704 @@ +bl_info = { + "name": "Stylized Tree Generator", + "author": "D4rkst3r", + "version": (1, 0, 0), + "blender": (4, 2, 0), + "location": "View3D > Sidebar > Tree Gen", + "description": "Parametrischer Baum-/Palmen-/Busch-Generator (Geometry Nodes) mit Wachstums-Stufen", + "category": "Add Mesh", +} + +# Parametrischer Stylized-Baum als Geometry-Nodes-Gruppe (Stamm + Aeste). +# Blaetter/Krone macht der Nutzer selbst - dieses Addon liefert das Geaest. +# +# HINWEIS: Der Node-Aufbau unten ist identisch mit EcoGame tools/blender_tree_gen.py +# (dort als CLI-Variante). Aenderungen bitte in beiden pflegen. +# +# LIVE BEARBEITEN: Nach dem Erzeugen liegen alle Regler am Modifier "GN_Tree" - +# im Viewport ziehen, der Baum aktualisiert sich sofort. + +import bpy +import sys +import os + +GROUP_NAME = "GN_StylizedTree" +OBJ_NAME = "StylizedTree" + +# ============================ REGLER (Defaults) ============================ +DEFAULTS = { + "Seed": 0, + "Height": 5.0, + "Trunk Radius": 0.13, # schlanker Stamm; zu dick wirkt sofort "stumpf" + "Bend": 0.5, + "Taper": 0.9, + "Branch Count": 9, + "Branch Length": 1.3, + "Branch Up": 1.3, # >1 = Aeste zeigen nach oben; negativ = haengend + "Branch Start": 0.35, # ab wo am Stamm Aeste sitzen (0..1) + "Branch End": 0.95, + "Branch Bend": 0.45, # Variation je Ast (0 = gerade Staebe) + "Branch Droop": 0.0, # >0 haengt nach unten (Palme), <0 kruemmt nach oben (Kaktus) + "Branch Thickness": 0.32, # Ast-Dicke relativ zum Stamm + "Branch Taper": 1.0, # 1 = laeuft spitz zu, 0.2 = bleibt dick (Kaktus) + "Sub Count": 3, # Sub-Aeste je Hauptast (0 = aus) + "Sub Length": 0.55, + "Sub Up": 1.4, + "Sides": 6, + "Ribs": 0.0, # senkrechte Kanneluren (0 = glatt); Kaktus ~9 + "Rib Depth": 0.0, +} + +# Presets: nur die Abweichungen von DEFAULTS. +PRESETS = { + "baum": {}, + "palme": { + "Height": 7.0, "Trunk Radius": 0.16, "Bend": 1.1, "Taper": 0.55, + "Branch Count": 11, "Branch Length": 2.6, "Branch Up": 0.55, + "Branch Start": 0.93, "Branch End": 1.0, + "Branch Bend": 0.25, "Branch Droop": 1.5, + "Sub Count": 0, # Wedel-Fiederung macht der User als Blattwerk + "Sides": 6, + }, + "busch": { + "Height": 1.6, "Trunk Radius": 0.07, "Bend": 0.4, "Taper": 0.8, + "Branch Count": 14, "Branch Length": 0.9, "Branch Up": 1.0, + "Branch Start": 0.15, "Branch End": 0.95, + "Branch Bend": 0.6, "Branch Droop": 0.0, + "Sub Count": 2, "Sub Length": 0.35, "Sub Up": 1.6, "Sides": 5, + }, + # Kaktus: dicker, kaum verjuengter Stamm, wenige Arme, die per NEGATIVEM + # Droop nach oben kruemmen (Saguaro-Silhouette). Keine Sub-Aeste. + "kaktus": { + "Height": 3.0, "Trunk Radius": 0.28, "Bend": 0.08, "Taper": 0.10, + "Branch Count": 2, "Branch Length": 1.6, "Branch Up": 0.05, + "Branch Start": 0.28, "Branch End": 0.5, + "Branch Bend": 0.0, + "Branch Droop": -2.2, # negativ = Arme kruemmen nach OBEN (Saguaro) + "Branch Thickness": 0.72, # Arme fast so dick wie der Stamm + "Branch Taper": 0.12, # Arme bleiben dick statt spitz zuzulaufen + "Sub Count": 0, + "Sides": 18, "Ribs": 9.0, "Rib Depth": 0.09, + }, +} +# =========================================================================== + + +def _sock(node, *names): + """Falle 1: Socket nach Namen holen, mit Alternativen.""" + for n in names: + if n in node.inputs: + return node.inputs[n] + raise KeyError("Socket %s nicht in %s" % (names, node.bl_idname)) + + +def _out(node, *names): + for n in names: + if n in node.outputs: + return node.outputs[n] + return node.outputs[0] + + +def build_group(): + old = bpy.data.node_groups.get(GROUP_NAME) + if old: + bpy.data.node_groups.remove(old) + ng = bpy.data.node_groups.new(GROUP_NAME, "GeometryNodeTree") + N, L = ng.nodes.new, ng.links.new + iface = ng.interface + + def _new_any(*bl_idnames): + """Falle 2: erster Node-Typ, den diese Blender-Version kennt.""" + for bid in bl_idnames: + try: + return N(bid) + except Exception: + continue + return None + + iface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') + + def add_in(name, stype, default, mn=None, mx=None): + s = iface.new_socket(name, in_out='INPUT', socket_type=stype) + s.default_value = default + if mn is not None: + s.min_value = mn + if mx is not None: + s.max_value = mx + + add_in("Seed", 'NodeSocketInt', DEFAULTS["Seed"], 0, 9999) + add_in("Height", 'NodeSocketFloat', DEFAULTS["Height"], 0.5, 30.0) + add_in("Trunk Radius", 'NodeSocketFloat', DEFAULTS["Trunk Radius"], 0.01, 3.0) + add_in("Bend", 'NodeSocketFloat', DEFAULTS["Bend"], 0.0, 3.0) + add_in("Taper", 'NodeSocketFloat', DEFAULTS["Taper"], 0.0, 1.0) + add_in("Branch Count", 'NodeSocketInt', DEFAULTS["Branch Count"], 0, 60) + add_in("Branch Length", 'NodeSocketFloat', DEFAULTS["Branch Length"], 0.1, 10.0) + add_in("Branch Up", 'NodeSocketFloat', DEFAULTS["Branch Up"], -3.0, 3.0) + add_in("Branch Start", 'NodeSocketFloat', DEFAULTS["Branch Start"], 0.0, 1.0) + add_in("Branch End", 'NodeSocketFloat', DEFAULTS["Branch End"], 0.0, 1.0) + add_in("Branch Bend", 'NodeSocketFloat', DEFAULTS["Branch Bend"], 0.0, 2.0) + # Droop darf NEGATIV sein: dann kruemmen sich die Aeste nach OBEN + # (= Kaktus-Arme statt Palmwedel). + add_in("Branch Droop", 'NodeSocketFloat', DEFAULTS["Branch Droop"], -3.0, 3.0) + add_in("Branch Thickness", 'NodeSocketFloat', DEFAULTS["Branch Thickness"], 0.05, 1.0) + add_in("Branch Taper", 'NodeSocketFloat', DEFAULTS["Branch Taper"], 0.0, 1.0) + add_in("Sub Count", 'NodeSocketInt', DEFAULTS["Sub Count"], 0, 12) + add_in("Sub Length", 'NodeSocketFloat', DEFAULTS["Sub Length"], 0.05, 5.0) + add_in("Sub Up", 'NodeSocketFloat', DEFAULTS["Sub Up"], -3.0, 3.0) + add_in("Sides", 'NodeSocketInt', DEFAULTS["Sides"], 3, 32) + add_in("Ribs", 'NodeSocketFloat', DEFAULTS["Ribs"], 0.0, 20.0) + add_in("Rib Depth", 'NodeSocketFloat', DEFAULTS["Rib Depth"], 0.0, 0.5) + + gin = N("NodeGroupInput"); gin.location = (-1400, 0) + gout = N("NodeGroupOutput"); gout.location = (1400, 0) + V = gin.outputs + + # ---------- Stamm ---------- + line = N("GeometryNodeCurvePrimitiveLine"); line.location = (-1150, 200) + top = N("ShaderNodeCombineXYZ"); top.location = (-1300, 120) + L(V["Height"], top.inputs["Z"]) + L(top.outputs[0], _sock(line, "End")) + + res = N("GeometryNodeResampleCurve"); res.location = (-950, 200) + _sock(res, "Count").default_value = 24 # genug Punkte, damit die Kuppel rund wird + L(_out(line, "Curve"), _sock(res, "Curve")) + + spar = N("GeometryNodeSplineParameter"); spar.location = (-950, -60) + pos = N("GeometryNodeInputPosition"); pos.location = (-1150, -220) + + noise = N("ShaderNodeTexNoise"); noise.location = (-950, -260) + noise.noise_dimensions = '4D' + _sock(noise, "Scale").default_value = 0.55 + L(pos.outputs[0], _sock(noise, "Vector")) + L(V["Seed"], _sock(noise, "W")) + + nsub = N("ShaderNodeVectorMath"); nsub.location = (-750, -260) + nsub.operation = 'SUBTRACT'; nsub.inputs[1].default_value = (0.5, 0.5, 0.5) + L(noise.outputs["Color"], nsub.inputs[0]) + + # Falle 5: Z des Offsets platt machen + flat = N("ShaderNodeVectorMath"); flat.location = (-580, -260) + flat.operation = 'MULTIPLY'; flat.inputs[1].default_value = (1.0, 1.0, 0.0) + L(nsub.outputs[0], flat.inputs[0]) + + # Falle 4: Offset mit Spline-Faktor skalieren -> Fuss bleibt stehen + bendf = N("ShaderNodeMath"); bendf.location = (-750, -60) + bendf.operation = 'MULTIPLY' + L(V["Bend"], bendf.inputs[0]) + L(spar.outputs["Factor"], bendf.inputs[1]) + + offs = N("ShaderNodeVectorMath"); offs.location = (-400, -200) + offs.operation = 'SCALE' + L(flat.outputs[0], offs.inputs[0]) + L(bendf.outputs[0], _sock(offs, "Scale")) + + setpos = N("GeometryNodeSetPosition"); setpos.location = (-250, 200) + L(_out(res, "Curve"), _sock(setpos, "Geometry")) + L(offs.outputs[0], _sock(setpos, "Offset")) + + # Radius: TrunkRadius * (1 - Taper * factor) + tf = N("ShaderNodeMath"); tf.location = (-580, 60); tf.operation = 'MULTIPLY' + L(V["Taper"], tf.inputs[0]); L(spar.outputs["Factor"], tf.inputs[1]) + inv = N("ShaderNodeMath"); inv.location = (-420, 60); inv.operation = 'SUBTRACT' + inv.inputs[0].default_value = 1.0 + L(tf.outputs[0], inv.inputs[1]) + trad0 = N("ShaderNodeMath"); trad0.location = (-260, 60); trad0.operation = 'MULTIPLY' + L(V["Trunk Radius"], trad0.inputs[0]); L(inv.outputs[0], trad0.inputs[1]) + + # Kuppel-Profil: (1 - f^3)^0.5 -> breite, gleichmaessige Kuppe. Mit einem + # zu spaeten/steilen Profil (f^8) trifft die Rundung nur 1-2 Resample- + # Punkte und wird zum KEGEL. Breit + genug Punkte = runde Kuppe. + # Bei starkem Taper (Baum) dominiert ohnehin die lineare Verjuengung. + def _dome(spar_node, x, y): + pw = N("ShaderNodeMath"); pw.location = (x, y); pw.operation = 'POWER' + pw.inputs[1].default_value = 3.0 + L(spar_node.outputs["Factor"], pw.inputs[0]) + s = N("ShaderNodeMath"); s.location = (x + 150, y); s.operation = 'SUBTRACT' + s.inputs[0].default_value = 1.0 + L(pw.outputs[0], s.inputs[1]) + rt = N("ShaderNodeMath"); rt.location = (x + 300, y); rt.operation = 'POWER' + rt.inputs[1].default_value = 0.5 + L(s.outputs[0], rt.inputs[0]) + # Radius NIE auf 0 laufen lassen: sonst entsteht immer eine Spitze, + # egal wie fein resampled wird. Mit Mindestradius + Fill Caps endet + # der Arm stumpf/gerundet statt als Dorn. + mx = N("ShaderNodeMath"); mx.location = (x + 450, y); mx.operation = 'MAXIMUM' + mx.inputs[1].default_value = 0.45 + L(rt.outputs[0], mx.inputs[0]) + return mx + + tdome = _dome(spar, -900, 220) + trad = N("ShaderNodeMath"); trad.location = (-100, 60); trad.operation = 'MULTIPLY' + L(trad0.outputs[0], trad.inputs[0]); L(tdome.outputs[0], trad.inputs[1]) + + setrad = N("GeometryNodeSetCurveRadius"); setrad.location = (-80, 200) + L(_out(setpos, "Geometry"), _sock(setrad, "Curve")) + L(trad.outputs[0], _sock(setrad, "Radius")) + + # ---------- Ast-Ursprünge auf dem oberen Stamm ---------- + trim = N("GeometryNodeTrimCurve"); trim.location = (100, 320) + L(_out(setrad, "Curve"), _sock(trim, "Curve")) + # Ansatzbereich der Aeste: Baum = breit gestreut, Palme = alles ganz oben. + try: + L(V["Branch Start"], _sock(trim, "Start")) + L(V["Branch End"], _sock(trim, "End")) + except KeyError: + pass + + c2p = N("GeometryNodeCurveToPoints"); c2p.location = (280, 320) + try: + c2p.mode = 'COUNT' # Falle 3 + except Exception: + pass + L(_out(trim, "Curve"), _sock(c2p, "Curve")) + L(V["Branch Count"], _sock(c2p, "Count")) + + # ---------- Ast-Geometrie (verjüngte Linie) ---------- + bline = N("GeometryNodeCurvePrimitiveLine"); bline.location = (100, 40) + btop = N("ShaderNodeCombineXYZ"); btop.location = (-60, -20) + L(V["Branch Length"], btop.inputs["Z"]) + L(btop.outputs[0], _sock(bline, "End")) + bres = N("GeometryNodeResampleCurve"); bres.location = (280, 40) + _sock(bres, "Count").default_value = 14 # zu wenig -> Kuppel wird zum Kegel + L(_out(bline, "Curve"), _sock(bres, "Curve")) + + # Ast-Verjuengung: 1 - BranchTaper*factor. Bei Kakteen klein halten, + # sonst laufen die Arme spitz zu wie Dornen. + bspar = N("GeometryNodeSplineParameter"); bspar.location = (280, -140) + btap = N("ShaderNodeMath"); btap.location = (360, -240); btap.operation = 'MULTIPLY' + L(V["Branch Taper"], btap.inputs[0]) + L(bspar.outputs["Factor"], btap.inputs[1]) + binv = N("ShaderNodeMath"); binv.location = (440, -140) + binv.operation = 'SUBTRACT'; binv.inputs[0].default_value = 1.0 + L(btap.outputs[0], binv.inputs[1]) + bmul = N("ShaderNodeMath"); bmul.location = (600, -140); bmul.operation = 'MULTIPLY' + L(V["Trunk Radius"], bmul.inputs[0]); L(binv.outputs[0], bmul.inputs[1]) + # Ast-Dicke relativ zum Stamm. Beim Kaktus muessen die Arme fast so dick + # sein wie der Stamm (0.7+), beim Baum deutlich duenner (~0.3). + bscl0 = N("ShaderNodeMath"); bscl0.location = (760, -140); bscl0.operation = 'MULTIPLY' + L(bmul.outputs[0], bscl0.inputs[0]) + L(V["Branch Thickness"], bscl0.inputs[1]) + bdome = _dome(bspar, 600, -600) + bscl = N("ShaderNodeMath"); bscl.location = (1080, -140); bscl.operation = 'MULTIPLY' + L(bscl0.outputs[0], bscl.inputs[0]); L(bdome.outputs[0], bscl.inputs[1]) + + bsetr = N("GeometryNodeSetCurveRadius"); bsetr.location = (600, 40) + L(_out(bres, "Curve"), _sock(bsetr, "Curve")) + L(bscl.outputs[0], _sock(bsetr, "Radius")) + + # ---------- radiale Ausrichtung je Ast ---------- + idx = N("GeometryNodeInputIndex"); idx.location = (100, 560) + frac = N("ShaderNodeMath"); frac.location = (260, 560); frac.operation = 'DIVIDE' + L(idx.outputs[0], frac.inputs[0]); L(V["Branch Count"], frac.inputs[1]) + ang = N("ShaderNodeMath"); ang.location = (420, 560); ang.operation = 'MULTIPLY' + ang.inputs[1].default_value = 6.283185 + L(frac.outputs[0], ang.inputs[0]) + cs = N("ShaderNodeMath"); cs.location = (580, 620); cs.operation = 'COSINE' + sn = N("ShaderNodeMath"); sn.location = (580, 500); sn.operation = 'SINE' + L(ang.outputs[0], cs.inputs[0]); L(ang.outputs[0], sn.inputs[0]) + dirv = N("ShaderNodeCombineXYZ"); dirv.location = (740, 560) + L(cs.outputs[0], dirv.inputs["X"]); L(sn.outputs[0], dirv.inputs["Y"]) + L(V["Branch Up"], dirv.inputs["Z"]) + + align = _new_any("FunctionNodeAlignRotationToVector", "FunctionNodeAlignEulerToVector") + if align is not None: + align.location = (900, 560) + try: + align.axis = 'Z' + except Exception: + pass + L(dirv.outputs[0], _sock(align, "Vector")) + + inst = N("GeometryNodeInstanceOnPoints"); inst.location = (1050, 320) + L(_out(c2p, "Points"), _sock(inst, "Points")) + L(_out(bsetr, "Curve"), _sock(inst, "Instance")) + if align is not None: + L(align.outputs[0], _sock(inst, "Rotation")) + + real = N("GeometryNodeRealizeInstances"); real.location = (1200, 320) + L(_out(inst, "Instances", "Geometry"), _sock(real, "Geometry")) + + # ---------- Ast-Biegung NACH dem Realize ---------- + # Trick fuer Variation je Ast: hier ist die Position bereits die WELT-Position + # des jeweiligen Astes. Dieselbe Noise liefert damit pro Ast einen anderen + # Wert — vor dem Realize haetten alle Instanzen identische lokale Coords + # (und wuerden exakt gleich gebogen). + rspar = N("GeometryNodeSplineParameter"); rspar.location = (1200, 60) + rpos = N("GeometryNodeInputPosition"); rpos.location = (1200, -100) + rnoise = N("ShaderNodeTexNoise"); rnoise.location = (1350, -140) + rnoise.noise_dimensions = '4D' + _sock(rnoise, "Scale").default_value = 0.9 + L(rpos.outputs[0], _sock(rnoise, "Vector")) + L(V["Seed"], _sock(rnoise, "W")) + rsub = N("ShaderNodeVectorMath"); rsub.location = (1500, -140) + rsub.operation = 'SUBTRACT'; rsub.inputs[1].default_value = (0.5, 0.5, 0.5) + L(rnoise.outputs["Color"], rsub.inputs[0]) + + # Staerke waechst zur Astspitze -> Ansatz bleibt am Stamm + rfac = N("ShaderNodeMath"); rfac.location = (1350, 60); rfac.operation = 'MULTIPLY' + L(V["Branch Bend"], rfac.inputs[0]) + L(rspar.outputs["Factor"], rfac.inputs[1]) + rscale = N("ShaderNodeVectorMath"); rscale.location = (1650, -140) + rscale.operation = 'SCALE' + L(rsub.outputs[0], rscale.inputs[0]) + L(rfac.outputs[0], _sock(rscale, "Scale")) + + # Droop: Spitzen haengen nach unten (Palmwedel) -- quadratisch = schoene Kurve + dsq = N("ShaderNodeMath"); dsq.location = (1350, -320); dsq.operation = 'MULTIPLY' + L(rspar.outputs["Factor"], dsq.inputs[0]); L(rspar.outputs["Factor"], dsq.inputs[1]) + dmul = N("ShaderNodeMath"); dmul.location = (1500, -320); dmul.operation = 'MULTIPLY' + L(V["Branch Droop"], dmul.inputs[0]); L(dsq.outputs[0], dmul.inputs[1]) + dneg = N("ShaderNodeMath"); dneg.location = (1650, -320); dneg.operation = 'MULTIPLY' + dneg.inputs[1].default_value = -1.0 + L(dmul.outputs[0], dneg.inputs[0]) + dvec = N("ShaderNodeCombineXYZ"); dvec.location = (1800, -320) + L(dneg.outputs[0], dvec.inputs["Z"]) + + roffs = N("ShaderNodeVectorMath"); roffs.location = (1800, -140) + roffs.operation = 'ADD' + L(rscale.outputs[0], roffs.inputs[0]) + L(dvec.outputs[0], roffs.inputs[1]) + + rsetp = N("GeometryNodeSetPosition"); rsetp.location = (1950, 320) + L(_out(real, "Geometry"), _sock(rsetp, "Geometry")) + L(roffs.outputs[0], _sock(rsetp, "Offset")) + + # ---------- Sub-Aeste (zweite Verzweigungsebene) ---------- + # Sitzen auf den bereits gebogenen Hauptaesten. Bei "Sub Count" = 0 liefert + # Curve to Points keine Punkte -> es entsteht schlicht nichts (kein Fehler). + strim = N("GeometryNodeTrimCurve"); strim.location = (2100, 520) + L(_out(rsetp, "Geometry"), _sock(strim, "Curve")) + try: + _sock(strim, "Start").default_value = 0.3 + _sock(strim, "End").default_value = 0.9 + except KeyError: + pass + sp = N("GeometryNodeCurveToPoints"); sp.location = (2250, 520) + try: + sp.mode = 'COUNT' + except Exception: + pass + L(_out(strim, "Curve"), _sock(sp, "Curve")) + L(V["Sub Count"], _sock(sp, "Count")) + + sline = N("GeometryNodeCurvePrimitiveLine"); sline.location = (2100, 760) + stop = N("ShaderNodeCombineXYZ"); stop.location = (1950, 700) + L(V["Sub Length"], stop.inputs["Z"]) + L(stop.outputs[0], _sock(sline, "End")) + sres = N("GeometryNodeResampleCurve"); sres.location = (2250, 760) + _sock(sres, "Count").default_value = 6 + L(_out(sline, "Curve"), _sock(sres, "Curve")) + + sspar = N("GeometryNodeSplineParameter"); sspar.location = (2250, 900) + sinv = N("ShaderNodeMath"); sinv.location = (2400, 900) + sinv.operation = 'SUBTRACT'; sinv.inputs[0].default_value = 1.0 + L(sspar.outputs["Factor"], sinv.inputs[1]) + srad = N("ShaderNodeMath"); srad.location = (2550, 900); srad.operation = 'MULTIPLY' + L(V["Trunk Radius"], srad.inputs[0]); L(sinv.outputs[0], srad.inputs[1]) + srad2 = N("ShaderNodeMath"); srad2.location = (2700, 900); srad2.operation = 'MULTIPLY' + srad2.inputs[1].default_value = 0.14 # deutlich duenner als Hauptaeste + L(srad.outputs[0], srad2.inputs[0]) + ssetr = N("GeometryNodeSetCurveRadius"); ssetr.location = (2550, 760) + L(_out(sres, "Curve"), _sock(ssetr, "Curve")) + L(srad2.outputs[0], _sock(ssetr, "Radius")) + + # Richtung: radial gestreut (Index) + "Sub Up" + sidx = N("GeometryNodeInputIndex"); sidx.location = (2100, 1060) + sfr = N("ShaderNodeMath"); sfr.location = (2250, 1060); sfr.operation = 'MULTIPLY' + sfr.inputs[1].default_value = 2.399963 # Goldener Winkel -> gute Streuung + L(sidx.outputs[0], sfr.inputs[0]) + scs = N("ShaderNodeMath"); scs.location = (2400, 1120); scs.operation = 'COSINE' + ssn = N("ShaderNodeMath"); ssn.location = (2400, 1000); ssn.operation = 'SINE' + L(sfr.outputs[0], scs.inputs[0]); L(sfr.outputs[0], ssn.inputs[0]) + sdir = N("ShaderNodeCombineXYZ"); sdir.location = (2550, 1060) + L(scs.outputs[0], sdir.inputs["X"]); L(ssn.outputs[0], sdir.inputs["Y"]) + L(V["Sub Up"], sdir.inputs["Z"]) + + salign = _new_any("FunctionNodeAlignRotationToVector", "FunctionNodeAlignEulerToVector") + if salign is not None: + salign.location = (2700, 1060) + try: + salign.axis = 'Z' + except Exception: + pass + L(sdir.outputs[0], _sock(salign, "Vector")) + + sinst = N("GeometryNodeInstanceOnPoints"); sinst.location = (2850, 520) + L(_out(sp, "Points"), _sock(sinst, "Points")) + L(_out(ssetr, "Curve"), _sock(sinst, "Instance")) + if salign is not None: + L(salign.outputs[0], _sock(sinst, "Rotation")) + sreal = N("GeometryNodeRealizeInstances"); sreal.location = (3000, 520) + L(_out(sinst, "Instances", "Geometry"), _sock(sreal, "Geometry")) + + join = N("GeometryNodeJoinGeometry"); join.location = (3150, 200) + L(_out(sreal, "Geometry"), join.inputs[0]) + L(_out(rsetp, "Geometry"), join.inputs[0]) + L(_out(setrad, "Curve"), join.inputs[0]) + + # ---------- Curve -> Mesh ---------- + # Falle 6: Der Profil-Radius wird mit dem Curve-Radius MULTIPLIZIERT. + # Profil deshalb auf 1.0 lassen waere richtig -- aber nur, wenn der + # Curve-Radius bereits die echte Staerke ist. Hier ist er das, also 1.0. + circ = N("GeometryNodeCurvePrimitiveCircle"); circ.location = (1350, -100) + L(V["Sides"], _sock(circ, "Resolution")) + _sock(circ, "Radius").default_value = 1.0 + + # ---------- Rippen (senkrechte Kanneluren, Kaktus-Signatur) ---------- + # Das Profil bekommt eine radiale Welle: Offset entlang der Punktrichtung, + # moduliert mit cos(Rippen * Winkel). Rib Depth = 0 -> glatter Kreis. + pspar = N("GeometryNodeSplineParameter"); pspar.location = (1350, -420) + pang = N("ShaderNodeMath"); pang.location = (1500, -420); pang.operation = 'MULTIPLY' + pang.inputs[1].default_value = 6.283185 + L(pspar.outputs["Factor"], pang.inputs[0]) + pribs = N("ShaderNodeMath"); pribs.location = (1650, -420); pribs.operation = 'MULTIPLY' + L(pang.outputs[0], pribs.inputs[0]) + L(V["Ribs"], pribs.inputs[1]) + pcos = N("ShaderNodeMath"); pcos.location = (1800, -420); pcos.operation = 'COSINE' + L(pribs.outputs[0], pcos.inputs[0]) + pamp = N("ShaderNodeMath"); pamp.location = (1950, -420); pamp.operation = 'MULTIPLY' + L(pcos.outputs[0], pamp.inputs[0]) + L(V["Rib Depth"], pamp.inputs[1]) + ppos = N("GeometryNodeInputPosition"); ppos.location = (1650, -560) + pdir = N("ShaderNodeVectorMath"); pdir.location = (1800, -560) + pdir.operation = 'NORMALIZE' + L(ppos.outputs[0], pdir.inputs[0]) + poff = N("ShaderNodeVectorMath"); poff.location = (2100, -520) + poff.operation = 'SCALE' + L(pdir.outputs[0], poff.inputs[0]) + L(pamp.outputs[0], _sock(poff, "Scale")) + pset = N("GeometryNodeSetPosition"); pset.location = (2250, -100) + L(_out(circ, "Curve"), _sock(pset, "Geometry")) + L(poff.outputs[0], _sock(pset, "Offset")) + + c2m = N("GeometryNodeCurveToMesh"); c2m.location = (1500, 200) + L(_out(join, "Geometry"), _sock(c2m, "Curve")) + L(_out(pset, "Geometry"), _sock(c2m, "Profile Curve")) + # Falle 7 (Blender 5.x!): "Curve to Mesh" wertet das Radius-Attribut NICHT + # mehr implizit aus, sondern hat einen eigenen "Scale"-Eingang. Ohne diese + # Verbindung bleibt der Stamm immer bei Profil-Radius 1.0 (= 2 m dick), + # egal was "Trunk Radius" sagt. Gemessen: Stammbreite konstant 1.995. + if "Scale" in c2m.inputs: + radattr = N("GeometryNodeInputRadius"); radattr.location = (1350, -260) + L(radattr.outputs[0], _sock(c2m, "Scale")) + try: + _sock(c2m, "Fill Caps").default_value = True + except KeyError: + pass + + shade = N("GeometryNodeSetShadeSmooth"); shade.location = (1650, 200) + L(_out(c2m, "Mesh", "Geometry"), _sock(shade, "Geometry")) + L(_out(shade, "Geometry"), gout.inputs[0]) + return ng + + +# Wachstums-Stufen: Faktoren/Werte fuer t = 0 (Setzling) .. 1 (ausgewachsen). +# Gleicher Seed -> dieselbe Baum-"Identitaet", nur juenger. Genau das braucht +# ein Wachstums-System, damit Stufe 1 und Stufe 4 wie DERSELBE Baum wirken. +GROWTH_YOUNG = { + "Height": 0.16, # Faktoren (werden mit dem Zielwert multipliziert) + "Trunk Radius": 0.30, + "Branch Length": 0.30, + "Branch Count": 0.35, + "Sub Count": 0.0, # Setzling hat noch keine Sub-Aeste +} + + +def growth_values(preset, t): + """Parameter fuer Wachstums-Fortschritt t (0 = Setzling, 1 = ausgewachsen).""" + full = dict(DEFAULTS) + full.update(PRESETS.get(preset, {})) + out = dict(full) + t = max(0.0, min(1.0, t)) + for key, young_factor in GROWTH_YOUNG.items(): + if key not in full: + continue + target = full[key] + young = target * young_factor + v = young + (target - young) * t + out[key] = max(1, int(round(v))) if isinstance(target, int) and key != "Sub Count" \ + else (int(round(v)) if isinstance(target, int) else v) + return out + + +def socket_ids(ng): + """Name -> Modifier-Key ('Socket_3'). Fuer Presets/Skripting.""" + out = {} + for s in ng.interface.items_tree: + if getattr(s, "in_out", "") == 'INPUT': + out[s.name] = s.identifier + return out + + +def make_object(ng, preset="baum", name=None, growth=None, seed=None): + name = name or OBJ_NAME + old = bpy.data.objects.get(name) + if old: + bpy.data.objects.remove(old, do_unlink=True) + me = bpy.data.meshes.new(name + "Mesh") + ob = bpy.data.objects.new(name, me) + bpy.context.scene.collection.objects.link(ob) + md = ob.modifiers.new("GN_Tree", 'NODES') + md.node_group = ng + + ids = socket_ids(ng) + if growth is None: + values = dict(DEFAULTS) + values.update(PRESETS.get(preset, {})) + else: + values = growth_values(preset, growth) + if seed is not None: + values["Seed"] = seed + for k, v in values.items(): + if k in ids: + md[ids[k]] = v + + bpy.context.view_layer.objects.active = ob + return ob + + + + +# --------------------------------------------------------------------------- +# Addon-UI +# --------------------------------------------------------------------------- + +from bpy.props import ( + IntProperty, FloatProperty, EnumProperty, BoolProperty, PointerProperty, +) +from bpy.types import Operator, Panel, PropertyGroup + + +def _preset_enum(self, context): + return [(k, k.capitalize(), "") for k in PRESETS] + + +class TreeGenSettings(PropertyGroup): + preset: EnumProperty(name="Preset", items=_preset_enum) + seed: IntProperty(name="Seed", default=0, min=0, max=9999) + use_growth: BoolProperty( + name="Wachstums-Stufen", default=False, + description="Erzeugt mehrere Stufen (Setzling .. ausgewachsen) mit gleichem Seed", + ) + stages: IntProperty(name="Anzahl Stufen", default=4, min=2, max=8) + spacing: FloatProperty(name="Abstand", default=4.0, min=0.0, max=20.0) + apply_modifier: BoolProperty( + name="Modifier anwenden", default=False, + description="Ergebnis als normales Mesh einfrieren (fuer den Export)", + ) + + +def _place(ob, x): + ob.location.x = x + + +class TREEGEN_OT_create(Operator): + bl_idname = "object.treegen_create" + bl_label = "Baum erzeugen" + bl_description = "Erzeugt einen parametrischen Baum (Regler danach am Modifier)" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + s = context.scene.tree_gen_settings + try: + ng = build_group() + except Exception as exc: # noqa: BLE001 + self.report({'ERROR'}, "Node-Gruppe fehlgeschlagen: %s" % exc) + return {'CANCELLED'} + try: + ng.asset_mark() + ng.asset_data.description = "Parametrischer Stylized-Baum (Stamm + Aeste)" + except Exception: + pass + + created = [] + x = 0.0 + try: + if s.use_growth: + for i in range(s.stages): + t = i / float(s.stages - 1) if s.stages > 1 else 1.0 + ob = make_object(ng, preset=s.preset, growth=t, seed=s.seed, + name="%s_%s_stage%d" % (OBJ_NAME, s.preset, i)) + _place(ob, x); x += s.spacing + created.append(ob) + else: + ob = make_object(ng, preset=s.preset, seed=s.seed, + name="%s_%s" % (OBJ_NAME, s.preset)) + created.append(ob) + except Exception as exc: # noqa: BLE001 + self.report({'ERROR'}, "Erzeugen fehlgeschlagen: %s" % exc) + return {'CANCELLED'} + + if s.apply_modifier: + for ob in created: + context.view_layer.objects.active = ob + try: + bpy.ops.object.modifier_apply(modifier="GN_Tree") + except RuntimeError as exc: + self.report({'WARNING'}, "Modifier-Apply: %s" % exc) + + context.view_layer.update() + total = 0 + dg = context.evaluated_depsgraph_get() + for ob in created: + try: + total += len(ob.evaluated_get(dg).data.vertices) + except Exception: + pass + self.report({'INFO'}, "%d Objekt(e) erzeugt, %d Verts gesamt." % (len(created), total)) + return {'FINISHED'} + + +class VIEW3D_PT_tree_generator(Panel): + bl_label = "Tree Generator" + bl_idname = "VIEW3D_PT_tree_generator" + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "Tree Gen" + + def draw(self, context): + 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") + + 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="Export") + box.prop(s, "apply_modifier") + + layout.separator() + layout.operator("object.treegen_create", icon='OUTLINER_OB_MESH') + layout.label(text="Feinjustierung: am Modifier 'GN_Tree'", icon='INFO') + + +classes = ( + TreeGenSettings, + TREEGEN_OT_create, + VIEW3D_PT_tree_generator, +) + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.Scene.tree_gen_settings = PointerProperty(type=TreeGenSettings) + + +def unregister(): + del bpy.types.Scene.tree_gen_settings + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register()