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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
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
|
||||
|
||||
# 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")
|
||||
# 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" }
|
||||
)
|
||||
|
||||
Write-Host "== extension build ==" -ForegroundColor Cyan
|
||||
# Alte Zips weg, damit geloeschte Versionen nicht im Listing haengen bleiben.
|
||||
Get-ChildItem $dist -Filter *.zip -ErrorAction SilentlyContinue | Remove-Item -Force
|
||||
|
||||
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 "extension build fehlgeschlagen (Exit $LASTEXITCODE)" }
|
||||
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
|
||||
|
||||
Vendored
+27
-2
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+102
-54
@@ -20,7 +20,7 @@ import random
|
||||
import traceback
|
||||
|
||||
from bpy.props import (
|
||||
IntProperty, FloatProperty, EnumProperty, BoolProperty,
|
||||
IntProperty, FloatProperty, FloatVectorProperty, EnumProperty, BoolProperty,
|
||||
StringProperty, PointerProperty,
|
||||
)
|
||||
from bpy.types import Operator, Panel, PropertyGroup
|
||||
@@ -749,6 +749,15 @@ class RockGenSettings(PropertyGroup):
|
||||
bake_normal: BoolProperty(name="Normal", default=True)
|
||||
bake_roughness: BoolProperty(name="Roughness", default=True)
|
||||
bake_ao: BoolProperty(name="AO / Cavity", default=True)
|
||||
bake_base_color: FloatVectorProperty(
|
||||
name="Grundfarbe", subtype='COLOR', size=4,
|
||||
default=(0.52, 0.47, 0.40, 1.0), min=0.0, max=1.0,
|
||||
description="Grundton des Steins; Outline/Highlight werden davon abgeleitet",
|
||||
)
|
||||
bake_cell_scale: FloatProperty(
|
||||
name="Zellgroesse", default=2.5, min=0.5, max=12.0,
|
||||
description="Kleiner = groessere Zellen (weniger Bruchstuecke)",
|
||||
)
|
||||
|
||||
# --- FBX-Export ---
|
||||
export_dir: StringProperty(
|
||||
@@ -937,13 +946,18 @@ class OBJECT_OT_export_rocks_fbx(Operator):
|
||||
# Textur-Bake (nahtlos tileable via 4D-Torus-Projektion, fuer Triplanar in UE)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_bake_material():
|
||||
"""Baut ein prozedurales, nahtlos tileables Stylized-Rock-Material.
|
||||
def _build_bake_material(base_color=(0.52, 0.47, 0.40), cell_scale=2.5):
|
||||
"""Baut ein prozedurales, nahtlos tileables Stylized-Rock-Material (Zell-Look).
|
||||
|
||||
Trick: Die 2D-UV wird als (cos u, sin u, cos v, sin v) auf einen Torus
|
||||
projiziert und als 4D-Noise gesampelt -> 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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user