references/recipes.md
# gn_builder recipes (specs verified on 5.2)
Pass these as `spec=` (MCP) or save them as JSON for `--spec`. Run `--describe` to check socket names when you change node types.
## Scatter instances on a surface
```json
{"name": "GN_Scatter",
"inputs": [["Geometry","GEOMETRY"], ["Density","FLOAT",10,{"min":0}], ["Seed","INT",0],
["Instance","OBJECT"], ["Scale Min","FLOAT",0.8], ["Scale Max","FLOAT",1.2]],
"outputs": [["Geometry","GEOMETRY"]],
"nodes": {
"dist": ["GeometryNodeDistributePointsOnFaces", {"distribute_method":"RANDOM"}],
"obj": ["GeometryNodeObjectInfo", {"transform_space":"RELATIVE"}],
"rnd": ["FunctionNodeRandomValue", {"data_type":"FLOAT"}],
"inst": ["GeometryNodeInstanceOnPoints"],
"join": ["GeometryNodeJoinGeometry"]},
"links": [["in.Geometry","dist.Mesh"], ["in.Density","dist.Density"], ["in.Seed","dist.Seed"],
["in.Instance","obj.Object"], ["dist.Points","inst.Points"], ["obj.Geometry","inst.Instance"],
["dist.Rotation","inst.Rotation"],
["in.Scale Min","rnd.Min"], ["in.Scale Max","rnd.Max"], ["in.Seed","rnd.Seed"], ["rnd.Value","inst.Scale"],
["inst.Instances","join.Geometry"], ["in.Geometry","join.Geometry"], ["join.Geometry","out.Geometry"]]}
```
5.2: Random Value has dynamic sockets (`Min`, `Max`, `ID`, `Seed` → `Value`) whose type follows `data_type`. Older tutorials use index-based duplicates, which no longer exist. Distribute Points outputs `Rotation` (aligned to the normal) directly.
## Pipe / cable along a curve
```json
{"name": "GN_Pipe",
"inputs": [["Geometry","GEOMETRY"], ["Radius","FLOAT",0.05], ["Resolution","INT",12]],
"outputs": [["Geometry","GEOMETRY"]],
"nodes": {"circle": ["GeometryNodeCurvePrimitiveCircle"],
"c2m": ["GeometryNodeCurveToMesh", {}, {"Fill Caps": true}],
"smooth": ["GeometryNodeSetShadeSmooth"]},
"links": [["in.Resolution","circle.Resolution"], ["in.Radius","circle.Radius"],
["in.Geometry","c2m.Curve"], ["circle.Curve","c2m.Profile Curve"],
["c2m.Mesh","smooth.Geometry"], ["smooth.Geometry","out.Geometry"]]}
```
Apply to a curve object (`bpy.data.curves.new(..., "CURVE")`). Add `Resample Curve` for even segments.
## Repeat zone (iterative subdivide)
```json
{"name": "GN_Repeat", "inputs": [["Geometry","GEOMETRY"], ["Count","INT",3]], "outputs": [["Geometry","GEOMETRY"]],
"nodes": {"rin": ["GeometryNodeRepeatInput"], "rout": ["GeometryNodeRepeatOutput"],
"sub": ["GeometryNodeSubdivideMesh", {}, {"Level": 1}]},
"pairs": [["rin","rout"]],
"links": [["in.Count","rin.Iterations"], ["in.Geometry","rin.Geometry"], ["rin.Geometry","sub.Mesh"],
["sub.Mesh","rout.Geometry"], ["rout.Geometry","out.Geometry"]]}
```
The repeat output starts with one `Geometry` item. Add more state with the 4th node field: `{"repeat_items": [["GEOMETRY","Geometry"], ["FLOAT","Value"]]}`.
## Store a mask attribute for shaders/export
```json
{"name": "GN_HeightMask", "inputs": [["Geometry","GEOMETRY"]], "outputs": [["Geometry","GEOMETRY"]],
"nodes": {"pos": ["GeometryNodeInputPosition"], "sep": ["ShaderNodeSeparateXYZ"],
"store": ["GeometryNodeStoreNamedAttribute", {"data_type":"FLOAT","domain":"POINT"}, {"Name":"_height"}]},
"links": [["in.Geometry","store.Geometry"], ["pos.Position","sep.Vector"], ["sep.Z","store.Value"],
["store.Geometry","out.Geometry"]]}
```
Read it in a material with an Attribute node (`attribute_name="_height"`). glTF exports `_`-prefixed attributes with `export_attributes=True`.
## Shader tree with the same builder
```json
{"tree": "shader", "material": "M_Rust",
"nodes": {"out": ["ShaderNodeOutputMaterial"], "bsdf": ["ShaderNodeBsdfPrincipled"],
"noise": ["ShaderNodeTexNoise", {}, {"Scale": 8.0}], "ramp": ["ShaderNodeValToRGB"]},
"links": [["noise.Fac","ramp.Fac"], ["ramp.Color","bsdf.Base Color"], ["bsdf.BSDF","out.Surface"]]}
```
scripts/_bpyx.py
"""_bpyx - tiny shared helpers for blender-agent-skills scripts (Blender 5.2+).
Source of truth: shared/_bpyx.py. Copies in skills/*/scripts/ are synced by
tools/sync_shared.py - never edit a copy.
Every skill script follows one contract:
run(**kwargs) -> dict # call from MCP / Python (import the module)
blender -b f.blend -P x.py -- --arg v # CLI: prints one line "@@JSON@@{...}"
"""
import argparse
import json
import sys
import bpy
MARK = "@@JSON@@"
def argv():
"""Arguments after '--' (Blender ignores them)."""
return sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
def _default(o):
if hasattr(o, "__len__") and not isinstance(o, str):
try:
return [round(v, 5) if isinstance(v, float) else v for v in o]
except TypeError:
pass
if isinstance(o, (set, frozenset)):
return sorted(o)
return str(o)
def emit(data):
"""Print compact JSON on one marked line (parsed by run_in_blender.py)."""
print(MARK + json.dumps(data, separators=(",", ":"), default=_default), flush=True)
return data
def parser(doc):
"""ArgumentParser whose help is the script docstring's first line."""
return argparse.ArgumentParser(description=(doc or "").strip().splitlines()[0])
def cli(run, p):
"""CLI entry: parse args after '--', run, emit JSON. Exit 1 on failure."""
args = p.parse_args(argv())
try:
result = run(**vars(args))
save = getattr(args, "save", None)
if save:
bpy.ops.wm.save_as_mainfile(filepath=bpy.path.abspath(save))
result["saved"] = save
emit(result)
except Exception as e: # report, don't dump a traceback into agent context
emit({"ok": False, "error": f"{type(e).__name__}: {e}"})
if bpy.app.background:
sys.exit(1)
def add_common(p, save=True):
"""--objects / --save flags shared by most scripts."""
p.add_argument("--objects", nargs="*", default=None,
help="object names (default: selected, else all meshes)")
if save:
p.add_argument("--save", default=None, help="save .blend to this path after run")
return p
def objects(names=None, types=("MESH",)):
"""Resolve names -> objects. No names: selected of `types`, else all visible of `types`."""
if names:
missing = [n for n in names if n not in bpy.data.objects]
if missing:
raise KeyError(f"objects not found: {missing}")
return [bpy.data.objects[n] for n in names]
pool = [o for o in bpy.context.scene.objects if not types or o.type in types]
sel = [o for o in pool if o.select_get()]
return sel or [o for o in pool if o.visible_get()]
def object_mode():
"""Leave edit/sculpt/pose modes; most data API work needs OBJECT mode."""
ob = bpy.context.view_layer.objects.active
if ob and ob.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
def select_only(objs, active=None):
"""Select exactly `objs` and make `active` (default first) active."""
for o in bpy.context.view_layer.objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = active or (objs[0] if objs else None)
def node(tree, type_):
"""First node of `type_` (e.g. 'BSDF_PRINCIPLED'). Names are localized - never look up by name."""
return next((n for n in tree.nodes if n.type == type_), None)
def enum_ids(owner, prop):
"""Valid identifiers of an enum property (works on types or instances)."""
return [i.identifier for i in owner.bl_rna.properties[prop].enum_items]
def set_enum(owner, prop, value):
"""Assign an enum; on failure raise listing valid values (dynamic enums under-report)."""
try:
setattr(owner, prop, value)
except TypeError as e:
raise ValueError(f"{prop}={value!r} invalid: {e}") from None
def mesh_tris(ob, evaluated=True):
"""Triangle count (evaluated = with modifiers)."""
if evaluated:
eo = ob.evaluated_get(bpy.context.evaluated_depsgraph_get())
me = eo.to_mesh()
try:
me.calc_loop_triangles()
return len(me.loop_triangles)
finally:
eo.to_mesh_clear()
me = ob.data
me.calc_loop_triangles()
return len(me.loop_triangles)
def r(v, n=4):
"""Round a float or a vector for compact JSON."""
if isinstance(v, float):
return round(v, n)
return [round(x, n) for x in v]
scripts/gn_builder.py
"""Build node trees (Geometry Nodes, shader, compositor) from a compact JSON spec, apply them as
modifiers with input values (5.2 RNA API), and describe node types' sockets.
Spec (JSON file or dict):
{ "name": "Scatter", "tree": "geometry" | "shader" | "compositor",
"material": "M_X", # shader: build inside this material
"inputs": [["Geometry","GEOMETRY"], ["Density","FLOAT",10,{"min":0}]],
"outputs": [["Geometry","GEOMETRY"]],
"nodes": { "dist": ["GeometryNodeDistributePointsOnFaces", {"distribute_method":"RANDOM"},
{"Density": 5.0}], # [type, props, input defaults]
"rep_in": ["GeometryNodeRepeatInput"], "rep_out": ["GeometryNodeRepeatOutput", {},
{}, {"repeat_items": [["GEOMETRY","Geometry"]]}] }, # 4th: item collections
"pairs": [["rep_in","rep_out"]], # zones: repeat / simulation / foreach / closure
"links": [["in.Geometry","dist.Mesh"], ["dist.Points","inst.Points"], ["math.0","x.1"]] }
Socket refs: node.SocketName or node.<index>; "in"/"out" = group input/output.
Usage:
blender -b f.blend -P gn_builder.py -- --spec tree.json [--apply Plane] [--set Density=20] [--save out.blend]
blender -b -P gn_builder.py -- --describe GeometryNodeDistributePointsOnFaces [ShaderNodeMix ...]
MCP: import gn_builder as g; print(g.run(spec={...}, apply="Plane", set=["Density=20"]))
"""
import json
import os
import sys
import bpy
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
SOCK = {k: f"NodeSocket{v}" for k, v in {
"GEOMETRY": "Geometry", "FLOAT": "Float", "INT": "Int", "BOOLEAN": "Bool", "BOOL": "Bool",
"VECTOR": "Vector", "COLOR": "Color", "RGBA": "Color", "STRING": "String", "OBJECT": "Object",
"COLLECTION": "Collection", "MATERIAL": "Material", "IMAGE": "Image", "ROTATION": "Rotation",
"MATRIX": "Matrix", "MENU": "Menu", "SHADER": "Shader", "BUNDLE": "Bundle", "CLOSURE": "Closure"}.items()}
TREE = {"geometry": "GeometryNodeTree", "shader": "ShaderNodeTree", "compositor": "CompositorNodeTree"}
def describe(types):
"""Inputs/outputs (name, type, default) of node types, from a throwaway tree."""
out = {}
for t in types:
kind = ("GeometryNodeTree" if t.startswith("GeometryNode") or t.startswith(("FunctionNode", "Node"))
else "CompositorNodeTree" if t.startswith("CompositorNode") else "ShaderNodeTree")
ng = bpy.data.node_groups.new("__describe__", kind)
try:
n = ng.nodes.new(t)
def sock(s):
d = getattr(s, "default_value", None)
if d is not None and hasattr(d, "__len__") and not isinstance(d, str):
d = [round(x, 4) for x in d]
elif isinstance(d, float):
d = round(d, 4)
return [s.name, s.type, d] if d is not None and not isinstance(d, bpy.types.ID) else [s.name, s.type]
props = {p.identifier: [i.identifier for i in p.enum_items] if p.type == "ENUM" else p.type
for p in n.bl_rna.properties if not p.is_readonly
and p.identifier not in bpy.types.Node.bl_rna.properties}
out[t] = {"inputs": [sock(s) for s in n.inputs if s.enabled],
"outputs": [sock(s) for s in n.outputs if s.enabled], "props": props}
except RuntimeError as e:
out[t] = {"error": str(e)}
finally:
bpy.data.node_groups.remove(ng)
return out
def _sock(nodes, ref, outputs):
name, _, key = ref.partition(".")
n = nodes[name]
coll = n.outputs if outputs else n.inputs
if key.isdigit():
return coll[int(key)]
s = next((s for s in coll if s.name == key and s.enabled), None) or coll.get(key)
if s is None:
raise KeyError(f"{ref}: no {'output' if outputs else 'input'} '{key}' on {n.bl_idname}; "
f"have {[s.name for s in coll if s.enabled]}")
return s
def build(spec):
kind = spec.get("tree", "geometry")
if kind == "shader" and spec.get("material"):
mat = bpy.data.materials.get(spec["material"]) or bpy.data.materials.new(spec["material"])
nt = mat.node_tree
if spec.get("clear", True):
nt.nodes.clear()
else:
nt = bpy.data.node_groups.get(spec["name"])
if nt is None:
nt = bpy.data.node_groups.new(spec["name"], TREE[kind])
nt.nodes.clear()
nt.interface.clear()
for io, key in (("INPUT", "inputs"), ("OUTPUT", "outputs")):
for item in spec.get(key, []):
s = nt.interface.new_socket(item[0], in_out=io, socket_type=SOCK[item[1].upper()])
if len(item) > 2 and item[2] is not None and hasattr(s, "default_value"):
s.default_value = item[2]
for k, v in (item[3] if len(item) > 3 else {}).items():
setattr(s, {"min": "min_value", "max": "max_value"}.get(k, k), v)
if kind == "geometry":
nt.is_modifier = True
nodes = {}
if kind != "shader" or not spec.get("material"):
nodes["in"] = nt.nodes.new("NodeGroupInput")
nodes["out"] = nt.nodes.new("NodeGroupOutput")
for key, val in spec.get("nodes", {}).items():
val = [val] if isinstance(val, str) else val
n = nt.nodes.new(val[0])
n.name = n.label = key
for k, v in (val[1] if len(val) > 1 else {}).items():
if k == "data_type" and hasattr(n, "data_type"):
n.data_type = v
else:
setattr(n, k, bpy.data.objects.get(v) if k == "object" else v)
for coll_name, items in (val[3] if len(val) > 3 else {}).items():
coll = getattr(n, coll_name)
coll.clear()
for it in items:
coll.new(*it)
nodes[key] = n
for a, b in spec.get("pairs", []):
nodes[a].pair_with_output(nodes[b])
for key, val in spec.get("nodes", {}).items(): # defaults after pairing (zone sockets exist now)
val = [val] if isinstance(val, str) else val
for k, v in (val[2] if len(val) > 2 else {}).items():
s = _sock(nodes, f"{key}.{k}", outputs=False)
s.default_value = bpy.data.objects.get(v, v) if isinstance(v, str) and s.type == "OBJECT" else v
for a, b in spec.get("links", []):
nt.links.new(_sock(nodes, a, True), _sock(nodes, b, False))
_layout(nt)
return nt
def _layout(nt):
"""Column layout by longest path from inputs (readable trees for humans)."""
depth = {n: 0 for n in nt.nodes}
for _ in range(len(nt.nodes)):
changed = False
for l in nt.links:
d = depth[l.from_node] + 1
if d > depth[l.to_node]:
depth[l.to_node], changed = d, True
if not changed:
break
rows = {}
for n, d in sorted(depth.items(), key=lambda kv: kv[1]):
r = rows.get(d, 0)
n.location = (d * 260, -r * 220)
rows[d] = r + 1
def apply(nt, ob_name, values):
ob = bpy.data.objects[ob_name]
mod = next((m for m in ob.modifiers if m.type == "NODES" and m.node_group == nt), None) \
or ob.modifiers.new(nt.name, "NODES")
mod.node_group = nt
ids = {i.name: i.identifier for i in nt.interface.items_tree if getattr(i, "in_out", "") == "INPUT"}
for k, v in values.items():
item = getattr(mod.properties.inputs, ids[k])
if isinstance(v, str) and v.startswith("@"): # @attr_name -> read from attribute
item.type, item.attribute_name = "ATTRIBUTE", v[1:]
else:
item.value = bpy.data.objects.get(v, v) if isinstance(v, str) else v
return mod
def _parse_val(s):
for t in (int, float):
try:
return t(s)
except ValueError:
pass
if "," in s:
return tuple(float(x) for x in s.split(","))
return {"true": True, "false": False}.get(s.lower(), s)
def run(spec=None, apply_to=None, set=None, describe_types=None, **_): # noqa: A002
if describe_types:
return {"ok": True, "nodes": describe(describe_types)}
if isinstance(spec, str):
with open(bpy.path.abspath(spec), encoding="utf-8") as f:
spec = json.load(f)
nt = build(spec)
res = {"ok": True, "tree": nt.name, "nodes": len(nt.nodes), "links": len(nt.links)}
if apply_to:
vals = dict(kv.split("=", 1) for kv in set or [])
mod = apply(nt, apply_to, {k: _parse_val(v) for k, v in vals.items()})
ob = bpy.data.objects[apply_to]
eo = ob.evaluated_get(bpy.context.evaluated_depsgraph_get())
me = eo.to_mesh()
res["evaluated"] = {"verts": len(me.vertices), "faces": len(me.polygons)} if me else None
eo.to_mesh_clear()
res["modifier"] = mod.name
warn = [w.message for w in getattr(mod, "node_warnings", [])]
if warn:
res["node_warnings"] = warn[:10]
return res
if __name__ == "__main__":
p = bx.parser(__doc__)
p.add_argument("--spec", default=None)
p.add_argument("--apply", dest="apply_to", default=None)
p.add_argument("--set", nargs="*", default=None)
p.add_argument("--describe", dest="describe_types", nargs="*", default=None)
p.add_argument("--save", default=None)
bx.cli(run, p)
SKILL.md
---
name: blender-geometry-nodes
description: Geometry Nodes in Blender 5.2 - fields, domains, attributes, instancing, zones (simulation/repeat/for-each), closures/bundles, lists, SDF grids, node tools, baking, plus a JSON-to-node-tree builder and socket inspector. Use for scattering, procedural assets/environments, parametric tools and modifier inputs.
license: MIT
metadata:
author: blender-agent-skills
version: "1.2.0"
---
# Geometry Nodes
The execution model is in `blender-core`. Build trees with `scripts/gn_builder.py`, a compact JSON spec that's far cheaper than raw bpy. Look up exact socket names first, because they change between versions:
```
scripts/gn_builder.py -- --describe GeometryNodeDistributePointsOnFaces GeometryNodeInstanceOnPoints
scripts/gn_builder.py -- --spec scatter.json --apply Ground --set Density=20 Seed=3 Mask=@paint_mask
```
`--set Name=@attr` feeds a modifier input from a named attribute. The result reports evaluated vert/face counts and node warnings. The spec format is in the script docstring; full examples are in [references/recipes.md](references/recipes.md).
## Core concepts (get these right or nothing works)
- **Geometry** flows left→right through sockets with **circle** shapes. **Fields** (diamond sockets) are functions evaluated per element **on the node that consumes them**, in that node's domain/context.
- **Domains**: Point, Edge, Face, Face Corner, Spline, Instance, Layer. `Capture Attribute` freezes a field at a point in the graph (before topology changes), and `Evaluate on Domain` converts between domains.
- **Attributes**: built-ins (`position`, `normal`, `sharp_face`, `material_index`, `UVMap`...) plus named ones via `Store Named Attribute`. Named attributes persist after the modifier. That's how GN data reaches shaders (Attribute node) and exporters (glTF `export_attributes`, which exports names starting with `_`).
- **Instances** are cheap (one mesh, many transforms). **Realize Instances** only when you must edit per-instance geometry or export a single mesh. Realize as late as possible.
- Inputs exposed on the group interface become **modifier inputs** (5.2: `mod.properties.inputs.<identifier>.value`). Panels group them, menu sockets give enums.
## Zones and advanced types
| Feature | Nodes | Use |
|---|---|---|
| Simulation zone | `GeometryNodeSimulationInput/Output` (paired) | per-frame state: growth, particles, erosion; bake to keep |
| Repeat zone | `GeometryNodeRepeatInput/Output` | iterate N times (L-systems, relaxation, recursive subdivision) |
| For Each Element | `GeometryNodeForeachGeometryElementInput/Output` | run a sub-graph per element (per building lot, per island) |
| Closures / Bundles (5.0+) | `NodeClosureInput/Output`, `NodeCombineBundle`/`NodeSeparateBundle`, Evaluate Closure | pass functions/structured data around; reusable "strategy" node groups |
| Lists (5.2) | `GeometryNodeFieldToList`, `ListGetItem`, `ListLength`, `SortList`, `FilterList` | real arrays, not fields |
| Grids/SDF (4.x–5.x) | `MeshToSDFGrid`, `SDFGridBoolean`, `SDFGridFillet`, `SDFGridOffset`, `GridToMesh` | smooth booleans, remeshing, fillets, voxel effects |
| Physics (5.2, experimental) | `GeometryNodeXPBDSolver` + bundles | node-based cloth/hair, see `blender-simulation` |
| Gizmos | `GizmoLinear/Dial/Transform` | viewport handles for inputs |
Zones need `input.pair_with_output(output)` (spec key `pairs`), plus items on the output node (`repeat_items`, `state_items`, ...; spec 4th node field).
## Procedure
1. Sketch the data flow: source geometry → selection/masks (fields) → generate/instance → transform/randomize → realize/merge → set materials/shading → output.
2. `--describe` every node type you'll use, then write the spec and build. Check `node_warnings` and the evaluated counts.
3. Expose only meaningful inputs (density, seed, scale range, materials), with min/max set.
4. Look at it (`look`), check `blender-asset-qa` if the result will be exported (realized geometry), and check `blender-game-optimization` for instance counts and tris.
5. Ship: either keep it procedural (the .blend/asset library), or apply the modifier / `visual_geometry_to_objects` for exporters.
## Rules
- Randomness: always expose a `Seed`, and use `Random Value` with the ID/Index field so results stay stable when counts change.
- Scale-independent setups use `Object Info` (Relative) and real units. Apply the object scale or read it explicitly.
- Materials: `Set Material` inside the tree. The material must exist in the file, and instances keep their own materials.
- Shading: GN output loses auto-smooth unless you add `Set Shade Smooth` + (5.x) the `Smooth by Angle` essentials group, or store `sharp_edge`.
- UVs: primitives output `UVMap` via their UV output. Store it with `Store Named Attribute` (Face Corner, 2D Vector, name `UVMap`) or it's dropped.
- Performance: avoid realizing millions of instances, prefer Points + instances for scatter, use `Merge by Distance` sparingly, and simulation zones need baking for playback/export (`object.geometry_node_bake_single` or modifier `bakes`).
- Node tools (`ng.is_tool = True`, `is_mode_edit` etc.) turn a group into an operator in the 3D View menus, for editing workflows.
## Gotchas
- 5.2: `mod["Socket_2"]` no longer works. Use `getattr(mod.properties.inputs, ident).value`, and `.type = "ATTRIBUTE"` + `.attribute_name` for attribute inputs. Compare and Random Value **sockets changed**: Random Value now has single dynamic `Min`/`Max`/`Value` sockets typed by `data_type`. Link by name, and `--describe` when unsure.
- Sockets that are disabled for the current mode/data type are still in `node.inputs` with the same name. The builder picks the **enabled** one. Set `data_type`/`mode` props first (the builder sets props before links).
- `nt.interface.new_socket(...)` is the 4.0+ API (`ng.inputs.new` is gone).
- Exporters see the **evaluated** mesh. Instances become glTF instancing (`export_gpu_instances`) or are realized on export.