Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Geometry Script is a scripting API for Blender’s Geometry Nodes. It makes complicated node trees more manageable and easy to share.

Here’s a simple example of what’s possible with a short script:

Geometry Script

from geometry_script import *

@tree("Repeat Grid")
def repeat_grid(geometry: Geometry, width: int = 3, height: int = 3):
    mesh = grid(
        size_x=width, size_y=height,
        vertices_x=width, vertices_y=height,
    ).mesh
    points = mesh_to_points(mesh=mesh).points
    return instance_on_points(points=points, instance=geometry).instances

Generated Node Tree

Installation

Geometry Script is a Blender extension and requires Blender 5.2 or newer.

Each build of the extension is generated for one Blender version, so its node functions match the nodes in that version exactly. Download the build that matches the version of Blender you use.

Drag and drop

Drag the box for your Blender version into Blender:

Blender asks to allow online access if it’s off, then offers to add the Geometry Script repository and install the extension. The nightly release lists the links for every Blender version with a build.

From GitHub

  1. Download the ZIP matching your Blender version from the nightly release, e.g. geometry_script-1.0.0-blender-5.2.zip
  2. Open Blender > Preferences > Get Extensions
  3. Open the dropdown at the top right, choose Install from Disk… and select the downloaded ZIP file

Building it yourself

To build the extension for a Blender version that has no published ZIP, clone the repository and run:

python tools/build.py --blender /path/to/blender

This regenerates the node functions with that Blender, runs a smoke test, and writes dist/geometry_script-<version>-blender-<major>.<minor>.zip.

Internal Editing Basics

The fastest way to get up and running is with Blender’s built-in Text Editor. You can edit and execute your scripts right inside of Blender:

  1. Open a Text Editor space.

  1. Create a new text data-block with the New button.

  1. Start writing a Geometry Script. As an example, you can paste in the script below. More detailed instructions on writing scripts are in later chapters.
from geometry_script import *

@tree("Repeat Grid")
def repeat_grid(geometry: Geometry, width: int = 3, height: int = 3):
    mesh = grid(
        size_x=width, size_y=height,
        vertices_x=width, vertices_y=height,
    ).mesh
    points = mesh_to_points(mesh=mesh).points
    return instance_on_points(points=points, instance=geometry).instances
  1. Click the run button to execute the script. This will create a Geometry Nodes tree named Repeat Grid.

  1. Create a Geometry Nodes modifier on any object in your scene and select the Repeat Grid tree.

If the script has a mistake, Blender reports the error (the full traceback is in the system console) and the previous version of the tree is left untouched. Errors name what to change, such as a misspelled input (unexpected keyword argument 'vertice_x'. Did you mean 'vertices_x'?) or an output that doesn’t exist (GeometryNodeMeshGridOutputs has no output 'geometry'; available: ['mesh', 'uv_map']).

External Editing

Blender’s Text Editor leaves a lot to be desired. Writing scripts without code completion can be tough. Using an external code editor is one way to improve the editing experience.

This guide will show how to setup Visual Studio Code to edit Geometry Scripts. However, the same concepts apply to other IDEs.

This guide assumes you have already installed Visual Studio Code and setup the Python extension. If not, please setup those tools before continuing.

Code Completion

Every node function in Geometry Script is generated with full type hints: its keyword arguments, their socket types and defaults, the allowed enum values, and a named tuple of its outputs. An editor only needs to find the geometry_script package to complete all of them.

  1. Find the folder Blender installed the extension into. For the default user repository this is the extensions/user_default folder inside Blender’s configuration directory:

    • Linux: ~/.config/blender/5.2/extensions/user_default
    • macOS: ~/Library/Application Support/Blender/5.2/extensions/user_default
    • Windows: %APPDATA%\Blender Foundation\Blender\5.2\extensions\user_default

    It contains a geometry_script folder.

  2. In VS Code, open the Settings UI (Shift+CTRL+P or Shift+CMD+P > Preferences > Open Settings (UI))

  3. Find the setting Python > Analysis: Extra Paths

  4. Click Add Item, then paste in the user_default path and click OK

  1. Create a new Python file, such as Repeat Grid.py and start writing a script. As you type, you should get helpful suggestions for every available node. Hovering a node function shows its signature and description, for example:
def instance_on_points(
    *,
    points: Geometry | Socket[Geometry] | None = None,
    selection: bool | Field[bool] | Socket[bool | Field[bool]] = True,
    instance: Geometry | Socket[Geometry] | None = None,
    pick_instance: bool | Field[bool] | Socket[bool | Field[bool]] = False,
    instance_index: int | Field[int] | Socket[int | Field[int]] = 0,
    rotation: Rotation | Field[Rotation] | Socket[Rotation | Field[Rotation]] = (0.0, 0.0, 0.0),
    scale: Vector | Field[Vector] | Socket[Vector | Field[Vector]] = (1.0, 1.0, 1.0),
) -> GeometryNodeInstanceOnPointsOutputs:
    'Generate a reference to geometry at each of the input points, without duplicating its underlying data'

For completions of bpy itself, install fake-bpy-module into the Python environment your editor uses.

Linking with Blender

Writing a script is great, but we want to see it run in Blender. Thankfully, Blender’s Text Editor lets us link with an external file, and a simple tool from Geometry Script can make the process more seamless:

  1. Open a Text Editor space.
  2. Click the open button in the top of the editor, and navigate to your Python file.
  3. Click the gear icon or press N, and uncheck Make Internal. This will ensure that changes made outside of Blender can be easily brought in.
  4. Click Open Text.

  1. At the top right of the Text Editor, open the Geometry Script menu and enable Auto Resolve. Enabling this feature will make the text data-block in Blender update every time you save the file outside of Blender.

  1. To enable hot reload, open the Text menu and enable Live Edit. This will re-run your Geometry Script whenever it changes, updating the node tree live.

Each live reload prints a short report to Blender’s system console: the trees that were built with their inputs and outputs, then the evaluated geometry (vertex, face, point and instance counts and bounds) and any node warnings for every object using them. If the script fails, the report shows the error and the line that caused it, and the previous tree is kept.

And that’s it! You’re setup to start writing scripts. In the next section we’ll take a look at the API, and all of the things you can do with it.

Command-Line and Agent Tools

Geometry Script includes a few tools that report on a script in a few lines of plain text, instead of tracebacks or screenshots. They are useful when running scripts outside of Blender’s UI, and for AI coding agents that build node trees.

Running a Script

agent.run builds the trees in a script, optionally attaches the last one to an object, evaluates it, and prints a short report:

from geometry_script import agent
agent.run("/path/to/Repeat Grid.py", object="Cube")
built 'Repeat Grid': 14 nodes; in (Geometry: Geometry, Columns: Int = 3, Rows: Int = 3, Spacing X: Float = 0.5, Spacing Y: Float = 0.5) -> out (Result: Geometry)
object 'Cube': instances 9; instance positions (-2.5, -2.5, 0)..(2.5, 2.5, 0)

Shader groups, materials, worlds and compositor groups are reported too:

built 'Stripes' (shader): 4 nodes; in (Color A: Color, Color B: Color, Scale: Float = 6) -> out (Result: Color)
built material 'Striped': 3 nodes; out (Surface)

object= names the object to add a Geometry Nodes modifier to, creating the object if it doesn’t exist. tree= picks which geometry tree to attach, instead of the last one built. If the script fails, the report shows the error and the line of your script that caused it. Pass traceback=True for the full traceback.

agent.status() prints the report of the latest run, including runs triggered by Live Edit.

To run a script in background Blender from a terminal, use tools/run.py from the repository. It loads Geometry Script from the checkout, so the extension doesn’t need to be installed:

python3 tools/run.py "examples/Repeat Grid.py" --object Cube
python3 tools/run.py "examples/City Builder.py" --object Roads --blend city.blend

Looking Up Nodes

python3 tools/describe.py --search "points on faces"   # find a node
python3 tools/describe.py set_position mesh_to_points  # print their signatures
python3 tools/describe.py --tree shader principled_bsdf  # shader or compositor nodes

Inside Blender, the same is available as agent.search(...) and agent.describe(...), which also take tree="shader" or tree="compositor".

Reading an Existing Tree

agent.describe_tree("Name") prints any node group, material or world, including ones made by hand, as Geometry Script calls. Inputs left at their default values are omitted.

Agent Skill

The repository includes a skill for AI coding agents in skills/geometry-script/SKILL.md. It explains the API and the run-and-fix loop above, including how to use these tools through Blender’s MCP server.

For Claude Code, install it as a plugin from this repository:

/plugin install geometry-script --marketplace carson-katri/geometry-script

Other agents can read the skill file directly from a checkout of the repository.

Basics

Creating Geometry Scripts can be as easy or complex as you want for your project. Throughout this guide, scripts will be displayed alongside the generated nodes to provide context on how a script relates to the underlying nodes.

Setting up an editor for external editing is recommended when writing scripts, but internal editing inside Blender will suffice for the simple examples shown here.

Modules

The first step when writing is script is importing the geometry_script module. There a are a few ways of doing this:

This will import every type and function available into your script. It can make it easy to discover what’s available with code completion, and makes the scripts more terse.

from geometry_script import *

cube(...) # Available globally
my_geo: Geometry # All types available as well

Import Specific Names

This will import only the specified names from the module:

from geometry_script import cube, Geometry

cube(...) # Available from import
my_geo: Geometry

Namespaced Import

This will import every type and function, and place them behind the namespace. You can use the module name, or provide your own.

import geometry_script

geometry_script.cube(...) # Prefix with the namespace
my_geo: geometry_script.Geometry
import geometry_script as gs

gs.cube(...) # Prefix with the custom name
my_geo: gs.Geometry

Shader and Compositor Modules

Shader and compositor nodes live in their own modules, geometry_script.shader and geometry_script.compositor, since each kind of tree accepts different nodes. They work with any of the import styles above:

from geometry_script import *
from geometry_script import shader as sh

sh.principled_bsdf(...)

See Shaders and Compositing for more.

Now that you have Geometry Script imported in some way, let’s create a tree.

Tree Functions

Node trees are created by decorating a function with @tree. Let’s try creating a simple tree function.

The code samples for the rest of the book assume you are importing all names with from geometry_script import *. However, if you are using a namespaced import, simply prefix the functions and types with geometry_script or your custom name.

@tree
def cube_tree():
    ...

By default, the name of your function will be used as the name of the generated node tree. However, you can specify a custom name by passing a string to @tree:

@tree("Cube Tree")
def cube_tree():
    ...

Running the script again rebuilds the tree in place. Modifiers using the tree keep their input values, and if the script raises an error, the previous version of the tree is left untouched.

The nodes are laid out for you each time the tree is built, in columns that flow from the Group Input on the left to the Group Output on the right, with each zone’s nodes kept together. Shader and compositor groups, materials and worlds are arranged the same way.

Group Output

The values you return become the outputs of the tree. Let’s try returning a simple cube.

@tree("Cube Tree")
def cube_tree():
    return cube().mesh

Here we call the cube(...) function, which creates a Cube node. Node functions return their outputs as a named tuple, so .mesh picks the Mesh output and connects it to the Group Output.

The Geometry Nodes modifier displays the first output, so a tree used as a modifier should return Geometry first.

You can also return multiple values as a tuple.

@tree("Cube Tree")
def cube_tree():
    return cube().mesh, 5

By default, the outputs are named Result, Result 2, and so on. To customize the names, return a dictionary.

@tree("Cube Tree")
def cube_tree():
    return {
        "My Cube": cube().mesh,
        "Scale Constant": 5
    }

Group Input

Each argument of a tree function becomes an input socket. Its annotation chooses the socket type. Let’s add a size argument to our Cube Tree.

@tree("Cube Tree")
def cube_tree(size: Vector):
    return cube(size=size).mesh

This creates a Size socket on the Group Input node and connects it to our cube.

The option is available on the Geometry Nodes modifier.

The available socket types match those in the UI. Python’s built-in types are used for the basic ones:

AnnotationSocket
floatFloat
intInteger
boolBoolean
strString
Vector, Vector2, Vector4Vector (3, 2 or 4 dimensions)
ColorColor
RotationRotation
MatrixMatrix
MenuMenu
GeometryGeometry
Object, Collection, Material, ImageData-block
Bundle, ClosureBundle, Closure

Wrap a type in Field[...], Grid[...] or List[...] to set the socket’s structure type, e.g. offset: Field[float] for an input that accepts a field.

In the next chapter, we’ll take a closer look at how sockets work, and what you can and cannot do with them.

Default Values

You can specify a default for any argument, and it will be set on the modifier when added:

@tree("Cube Tree")
def cube_tree(size: Vector = (1.0, 1.0, 1.0)):
    return cube(size=size).mesh

If an argument has a default but no annotation, its type is inferred from the default: count=4 becomes an Integer input and scale=0.5 a Float input.

Sockets

Because scripts are converted to Geometry Node trees, the values you work with in a script are not concrete numbers or meshes. They are sockets.

What is a socket?

A socket is any input or output on a node. Take the Cube node for example:

This node has 4 input sockets, and 2 output sockets.

  • Input Sockets
    • Size: Vector
    • Vertices X: int
    • Vertices Y: int
    • Vertices Z: int
  • Output Sockets
    • Mesh: Geometry
    • UV Map: Field[Vector]

A socket does not represent a value itself. For example, the Size socket does not necessarily represent the value (1, 1, 1). Instead, it can be connected to another node as an input, giving it a dynamic value.

When we write scripts, we typically deal with sockets, not concrete values like (1, 1, 1). Take this script for example:

@tree("Cube Tree")
def cube_tree(size: Vector):
    return cube(size=size).mesh

The size argument creates an input socket with the type Vector. This is then connected to the size socket of the Cube node.

Our script does not run every time the node tree is evaluated. It only runs once to create the node tree. Therefore, we have no way of knowing what value size has when the script runs, because it is dynamic.

What sockets can do

Sockets are great for passing values between nodes. A Geometry socket does not represent concrete vertices, edges, and faces. Instead, it represents the input or output socket of a node. This lets us use it to create connections between different nodes, by passing the output of one node to the input of another.

What sockets cannot do

Sockets cannot be read for their concrete value. A float socket does not equal 5 or 10 or 3.14 to our script. If you try to print(...) a socket, you will see which node and socket it refers to, such as Socket(Cube.Mesh), but no value.

For the same reason, Python’s if, and, or and not cannot look at a socket’s value. Use comparisons with nodes such as Switch and Boolean Math instead.

Literals

Anywhere a node input accepts a socket, you can also pass a Python value. It is stored as the input’s value:

cube(size=(2.0, 2.0, 2.0), vertices_x=4)

A tuple that contains sockets is combined for you with a Combine XYZ (or Combine Color) node:

@tree("Cube Tree")
def cube_tree(height: float):
    return cube(size=(1.0, 1.0, height)).mesh

Socket Math

Sockets can be used in math expressions. The proper Math node will be created automatically for you, so you can focus on writing a script and not thinking about sockets. If you use a float or int socket it will create a Math node, and if you use a Vector socket it will create a Vector Math node.

@tree("Cube Tree")
def cube_tree(size: Vector):
    doubled = size * (2, 2, 2) # Multiply each component by 2
    return cube(size=doubled).mesh

These operators are available:

  • Add (socket + 2)
  • Subtract (socket - 2)
  • Multiply (socket * 2)
  • Divide (socket / 2)
  • Modulo (socket % 2)
  • Power (socket ** 2)
  • Negate (-socket)

For any other operation, call the node function directly:

math_(operation="SINE", value=angle).value
vector_math(operation="NORMALIZE", vector=direction).vector

Comparisons

Comparison operators create a Compare node, which outputs a boolean socket:

is_tall = height > 2.0
is_even = index().index % 2 == 0

These operators are available:

  • Less than (socket < 2)
  • Less than or equal (socket <= 2)
  • Greater than (socket > 2)
  • Greater than or equal (socket >= 2)
  • Equal (socket == 2)
  • Not equal (socket != 2)

They work with float, integer, vector, color and string sockets, and pick the Compare node’s data type from both sides: an integer socket compared with a float compares as floats. Colors and strings only support == and !=. Equality on floats and vectors uses the node’s default epsilon of 0.001. For other settings, such as a different epsilon or a vector comparison mode, call the node function directly:

compare(a=direction, b=(0, 0, 1), operation="GREATER_THAN", data_type="VECTOR", mode="DOT_PRODUCT").result

Shader and compositor trees have no Compare node, so comparisons on float sockets there use Math nodes, which output 1.0 or 0.0.

The result is still a socket, so Python’s if cannot use it, and chained comparisons such as 0 < x < 1 do not work. Combine conditions with Boolean Math instead.

See Boolean Math for combining the results.

Vector Components

To read the x, y, and z components of a Vector socket, use a Separate XYZ node:

@tree("Cube Tree")
def cube_tree(size: Vector):
    components = separate_xyz(vector=size)
    # Multiply the height by 2 but leave the other components unchanged.
    return cube(size=(components.x, components.y, components.z * 2)).mesh

Connecting Nodes

To use the output of one node as the input of another, pass the output socket as a keyword argument:

@tree("Cube Tree")
def cube_tree(size: Vector):
    mesh = cube(size=size).mesh
    return mesh_to_volume(mesh=mesh).volume

The Mesh output of the Cube node is connected to the Mesh input of the Mesh to Volume node.

Storing intermediate sockets in variables keeps longer trees readable, and lets you connect one output to several inputs.

Using Nodes

Node functions are generated from the Blender version the extension is built for. Every node in that version is available, with the exact inputs, outputs and options it has there.

How nodes are mapped

All nodes are mapped to functions in the same way, so you can usually guess what a node is called. Using an IDE with code completion makes this even easier.

  1. The function name is the node’s name in snake case: Mesh to Points is mesh_to_points.
  2. Each input and property is a keyword argument, also in snake case. Node functions do not take positional arguments.
  3. The function returns a named tuple with one field per output, again in snake case.
result = mesh_to_points(mesh=grid().mesh, radius=0.1)
result.points # The Points output

Properties and inputs are both keyword arguments. A property is a value that cannot be connected to a socket. These are typically enums (displayed in the UI as a dropdown), with specific string values expected.

Names that clash with Python

Names that would hide a Python builtin get a trailing underscore: math_, id_, object_ and string_. math(...) also works for the Math node, while math.pi and the other members of Python’s math module keep working too.

Finding Nodes

To search the available nodes, or to print the signature of a node, use the describe tool from the repository. It doesn’t need Blender to run:

python3 tools/describe.py --search "points on faces"
python3 tools/describe.py mesh_to_points
mesh_to_points  # Mesh to Points: Generate a point cloud from a mesh's vertices
  mesh: Geometry
  selection: bool field = True
  position: Vector field = (0, 0, 0)
  radius: float field = 0.05
  mode: VERTICES|EDGES|FACES|CORNERS = 'VERTICES'
  -> .points: Geometry

Inside Blender, agent.search(...) and agent.describe(...) print the same thing:

from geometry_script import agent
agent.describe("mesh_to_points", "set_position")

Enum Properties

Many nodes have enum properties. For example, the Math node lets you choose which operation to perform. Pass the enum value as a string:

math_(operation="ADD", value=1.0, value_2=2.0)
math_(operation="SUBTRACT", value=1.0, value_2=2.0)

The allowed values are part of the function’s type hints, so they appear in code completion and a type checker will flag a typo. If you pass an invalid value, the error lists the valid ones:

Cannot set ShaderNodeMath.operation to 'SUBSTRACT'. Expected one of ['ADD', 'SUBTRACT', ...]. Did you mean 'SUBTRACT'?

Some nodes use Menu inputs instead of enum properties. These are passed as strings as well, using the name shown in the UI:

distribute_points_in_volume(volume=volume, mode="Grid")

Data Types

Nodes such as Switch, Capture Attribute, Random Value or Store Named Attribute can work with several data types. You usually don’t need to set data_type or input_type: it is inferred from what you connect.

# input_type is inferred as 'GEOMETRY'
switch(switch=use_sphere, false=cube().mesh, true=uv_sphere().mesh).output
# data_type is inferred as 'FLOAT_VECTOR'
random_value(min=(0.0, 0.0, 0.0), max=(1.0, 1.0, 1.0)).value

Set it explicitly to override the inferred type:

named_attribute(name="height", data_type="FLOAT").attribute

Duplicate Names

Some nodes use the same input name multiple times. For example, the Math node has three inputs named Value. The second and third get a number: value, value_2 and value_3.

math_(operation="WRAP", value=0.5, value_2=1.0, value_3=0.0)

Outputs That Depend on Settings

Some outputs only exist with certain settings. The Vector Math node, for example, outputs a Value instead of a Vector for DOT_PRODUCT. Accessing an output that the node doesn’t have with its current settings raises an error naming the outputs that do exist:

'Vector Math' has no 'vector' output with its current settings {'operation': 'DOT_PRODUCT'}; available: ['value'].

Examples

Here are two examples to show how a node maps to a function.

Cube

  1. Name: Cube -> cube
  2. Keyword Arguments
    • size: Vector
    • vertices_x: int
    • vertices_y: int
    • vertices_z: int
  3. Returns .mesh: Geometry and .uv_map: Field[Vector]

The node can now be used as a function:

cube() # All arguments are optional
cube(size=(1.0, 1.0, 1.0), vertices_x=3) # Optionally specify keyword arguments
cube_mesh = cube().mesh # Pick the Mesh output

Capture Attribute

Some nodes, such as Capture Attribute, have a list of items you add yourself. Their items are passed as extra keyword arguments, and each item’s output is available by the same name:

  1. Name Capture Attribute -> capture_attribute
  2. Keyword Arguments
    • geometry: Geometry
    • selection: Field[bool]
    • domain: 'POINT' | 'EDGE' | 'FACE' | ...
    • Any number of items, by name
  3. Returns .geometry: Geometry plus one output per item
result = capture_attribute(geometry=cube_mesh, domain="FACE", normal=normal().normal)
result.geometry # Access the geometry
result.normal # Access the captured normal

See Item Nodes for the other nodes that work this way.

Advanced Scripting

Now that we’ve covered the basics, let’s take a look at some more advanced scripting techniques.

Node Groups

A Geometry Script can have more than one tree function. Each tree function is a node group, and tree functions can be used in other tree functions to create Node Group nodes.

@tree("Instance Grid")
def instance_grid(instance: Geometry):
    """ Instance the input geometry on a grid """
    points = mesh_to_points(mesh=grid().mesh).points
    return instance_on_points(points=points, instance=instance).instances

@tree("Cube Grid")
def cube_grid():
    """ Create a grid of cubes """
    return instance_grid(instance=cube(size=(0.2, 0.2, 0.2)).mesh)

The Cube Grid tree uses the Instance Grid node group by calling the instance_grid function:

The Instance Grid node group uses the passed in instance argument to create a grid of instances:

Unlike node functions, tree functions accept positional arguments as well, in the order of their parameters: instance_grid(cube(...).mesh). Arguments you leave out use the parameter’s default.

A group with a single output returns that socket directly. A group with several outputs returns a named tuple with a field for each output. The field names are the output names, with any characters that can’t be used in a Python name replaced by underscores (an output named Bounding Box becomes .Bounding_Box):

@tree("Measure")
def measure(geometry: Geometry):
    bounds = bounding_box(geometry=geometry)
    return {"Size": bounds.max - bounds.min, "Center": (bounds.max + bounds.min) / 2}

@tree("Centered")
def centered(geometry: Geometry):
    center = measure(geometry).Center
    return set_position(geometry=geometry, offset=-center).geometry

Define a tree function before any tree that uses it, since each tree is built when its function is decorated.

This concept can scale to complex interconnected node trees, while keeping everything neatly organized in separate functions.

Functions vs Node Groups

You do not have to mark a function with @tree(...). If you don’t, function calls are treated as normal in Python. Any nodes created in the callee will be placed in the caller’s tree.

def instance_grid(instance): # Not marked with `@tree(...)`
    points = mesh_to_points(mesh=grid().mesh).points
    return instance_on_points(points=points, instance=instance).instances

@tree("Cube Grid")
def cube_grid(): # Marked with `@tree(...)`
    return instance_grid(cube(size=(0.2, 0.2, 0.2)).mesh)

The above example would place the Grid, Mesh to Points, and Instance on Points nodes in the main “Cube Grid” tree. It could be rewritten as:

@tree("Cube Grid")
def cube_grid():
    points = mesh_to_points(mesh=grid().mesh).points
    return instance_on_points(points=points, instance=cube(size=(0.2, 0.2, 0.2)).mesh).instances

Plain Python functions are a good way to reuse a few nodes without creating a separate node group.

Generators

Python has support for generators using the yield keyword.

Tree functions can be written as generators. Each yielded value becomes a separate output, just like returning a tuple:

@tree("Primitive Shapes")
def primitive_shapes():
    yield cube().mesh        # Result
    yield uv_sphere().mesh   # Result 2
    yield 5                  # Result 3

This is handy when the outputs are computed in several steps, since each one can be yielded as soon as it’s ready.

Joining Geometry

Yielded geometry is not joined automatically. To output several geometries as one, pass a list to Join Geometry:

@tree("Primitive Shapes")
def primitive_shapes():
    return join_geometry(geometry=[
        cube().mesh,
        uv_sphere().mesh,
        cylinder().mesh,
    ]).geometry

Multi-input sockets, such as the Geometry input of Join Geometry or the Mesh input of Mesh Boolean, accept a list of sockets.

The first output is always displayed when using a Geometry Nodes modifier. Ensure it is a Geometry socket, unless you are using the function as a node group.

Attributes

An important concept in Geometry Nodes is attributes. Many trees capture attributes or transfer them from one domain to another.

Capturing Attributes

Capture Attribute evaluates fields on a geometry and keeps the values for later. Pass each value to capture as a keyword argument; its output has the same name.

@tree("Twist")
def twist(geometry: Geometry, amount: float = 1.0):
    captured = capture_attribute(
        geometry=geometry,
        height=separate_xyz(vector=position().position).z,
    )
    # captured.height still refers to the height before the geometry was moved
    moved = set_position(geometry=captured.geometry, offset=(0.0, 0.0, 1.0)).geometry
    return set_position(geometry=moved, offset=(captured.height * amount, 0.0, 0.0)).geometry

Use the Geometry returned from capture_attribute(...), not the original, for the captured values to be available.

You can capture several values at once, and choose the domain:

captured = capture_attribute(geometry=mesh, domain="FACE", normal=normal().normal, index=index().index)
captured.normal, captured.index

Named Attributes

Store an attribute by name with Store Named Attribute, and read it back with Named Attribute. The data type is inferred when storing; set it when reading.

@tree("Named Attributes")
def named_attributes(geometry: Geometry):
    # Store a value
    geometry = store_named_attribute(
        geometry=geometry,
        name="my_custom_attribute",
        value=0.5,
        domain="POINT",
    ).geometry
    # Read it back
    value = named_attribute(name="my_custom_attribute", data_type="FLOAT").attribute
    return set_position(geometry=geometry, offset=(0.0, 0.0, value)).geometry

A helper function can keep the name and type in one place:

def height_attribute():
    return named_attribute(name="height", data_type="FLOAT").attribute

Attribute Sampling

To read values from another geometry, use the sampling nodes: Sample Index, Sample Nearest, Sample Nearest Surface, Geometry Proximity, and so on.

# The position of the next point
next_position = sample_index(geometry=points, value=position().position, index=index().index + 1).value

# The color of the nearest point on another geometry
nearest = sample_nearest(geometry=other, sample_position=position().position).index
color = sample_index(geometry=other, value=named_attribute(name="color", data_type="FLOAT_COLOR").attribute, index=nearest).value

# Snap a sphere to the surface of a cube
snapped = geometry_proximity(geometry=cube().mesh, sample_position=position().position).position
set_position(geometry=uv_sphere().mesh, position=snapped).geometry

Boolean Math

The Boolean Math node gives access to common boolean operations, such as AND, NOT, XOR, etc. Use it together with comparisons (which create Compare nodes) to build conditions.

@tree("Select Top")
def select_top(geometry: Geometry, threshold: float = 0.5):
    z = separate_xyz(vector=position().position).z
    above = z > threshold
    is_even = index().index % 2 == 0
    selection = boolean_math(boolean=above, boolean_2=is_even, operation="AND").boolean
    return delete_geometry(geometry=geometry, selection=selection).geometry

NOT only uses the first input:

boolean_math(boolean=above, operation="NOT").boolean

You cannot use the Python keywords and, or, and not on sockets. The script only builds the node tree, so it never sees the values that would decide the result. Use Boolean Math nodes instead.

If a condition gets long, a small helper function keeps it readable:

def all_of(*conditions):
    result = conditions[0]
    for condition in conditions[1:]:
        result = boolean_math(boolean=result, boolean_2=condition, operation="AND").boolean
    return result

Item Nodes

Some nodes have a list of items you add yourself, instead of a fixed set of sockets. In Geometry Script, the items are passed to the node function and their outputs are available by name.

Capture Attribute and Format String

Pass each item as a keyword argument. The item’s socket type is inferred from its value.

captured = capture_attribute(geometry=mesh, height=separate_xyz(vector=position().position).z)
captured.height

label = format_string(format="Point {index}", index=index().index).string

Index Switch

Pass the items as a list, in order:

shape = index_switch(index=choice, items=[cube().mesh, uv_sphere().mesh, cylinder().mesh]).output

Pass the items as a dictionary from each menu entry’s name to its value. Connecting a tree’s Menu input to the switch gives the modifier a dropdown with those entries:

@tree("Shape Picker")
def shape_picker(shape: Menu = "Cube"):
    return menu_switch(menu=shape, items={
        "Cube": cube().mesh,
        "Sphere": uv_sphere().mesh,
    }).output

Bundles

Combine Bundle takes its items as keyword arguments. Separate Bundle takes a dictionary from each item’s name to its type, since the values come from the bundle:

bundle = combine_bundle(size=2.0, mesh=cube().mesh).bundle

parts = separate_bundle(bundle=bundle, items={"size": float, "mesh": Geometry})
parts.size, parts.mesh

Item outputs can also be read with square brackets, e.g. parts["size"], which is useful for names that aren’t valid Python identifiers.

Zones

A zone is a region of a node tree between a pair of input and output nodes, such as Repeat Input and Repeat Output. The nodes inside the zone are evaluated differently from the rest of the tree: repeated, run once per frame, run once per element, or packaged up to be evaluated later.

Geometry Script has a decorator for each kind of zone:

ZoneDecoratorRuns its nodesWorks in
Repeat@repeat_zoneA set number of times, feeding each result into the next iterationGeometry Nodes, shaders
Simulation@simulation_zoneOnce per frame, starting from the previous frame’s resultGeometry Nodes
For Each Element@for_each_element(domain=...)Once for each point, face, or other element of a geometryGeometry Nodes
Closure@closure_zoneWhenever the closure is evaluatedGeometry Nodes, shaders

Writing a Zone

The decorated function is the body of the zone. Its parameters become the zone’s items, and the values it returns are connected to the zone’s output node:

@repeat_zone
def grow(geometry: Geometry, size: float):
    bigger = transform_geometry(geometry=geometry, scale=(1.1, 1.1, 1.1)).geometry
    return bigger, size * 1.1
  • Each parameter’s annotation sets the item’s socket type, just like the inputs of a tree function. Without an annotation, the type is inferred from the value passed in.
  • Return one value per item, in the same order as the parameters. You can also return a dictionary keyed by parameter name.
  • Some parameter names are special, and receive a value from the zone instead of being an item: iteration in a repeat zone, delta_time in a simulation zone, and index and element in a for-each zone.

Using a Zone

Call the decorated function inside a tree function to add the zone to the tree. Pass the initial value of each item, positionally or by name, along with any settings of the zone itself, such as iterations= or skip=:

@tree("Growing Cube")
def growing_cube(steps: int = 4):
    result = grow(cube().mesh, 1.0, iterations=steps)
    return result.geometry, result.size

A zone with a single item returns that socket directly. A zone with several items returns a named tuple with a field per item. For-each and closure zones return their own kinds of results, described on their pages.

Each call adds a new zone, so the same decorated function can be used several times, in one tree or in many. Zones can only be called while a tree is being built.

A zone can also be defined inside the tree function that uses it, which lets its body use the tree’s inputs directly:

@tree("Spiral")
def spiral(count: int = 12, turn: float = 0.5):
    @repeat_zone
    def step(geometry: Geometry, iteration: int):
        point = points(position=(math_(operation="COSINE", value=iteration * turn).value,
                                 math_(operation="SINE", value=iteration * turn).value,
                                 iteration * 0.1)).points
        return join_geometry(geometry=[geometry, point]).geometry

    return step(points(count=0).points, iterations=count)

The next pages cover each kind of zone in detail.

Repeat Zones

Using a Repeat Input and Repeat Output node, you can loop a block of nodes for a specific number of iterations.

Use the @repeat_zone decorator to create these linked nodes. The function’s parameters become the zone’s items, and the function returns their new values in the same order.

@repeat_zone
def doubler(value: float):
    return value * 2

@tree("Test Loop")
def test_loop():
    # Double the input value 5 times.
    return points(count=doubler(1.0, iterations=5)).points

When calling the repeat zone, pass the initial value of each item, then the number of iterations as iterations=.

A zone can also be defined inside the tree function that uses it.

Multiple Items

When a repeat zone has multiple items, return a tuple from the zone. Calling it returns a named tuple with a field per item:

@repeat_zone
def multi_doubler(value1: float, value2: float):
    return value1 * 2, value2 * 2

@tree("Multi Doubler")
def multi_doubler_tree():
    result = multi_doubler(1.0, 2.0, iterations=3)
    return result.value1, result.value2

You can also return a dictionary from the zone, keyed by parameter name.

Iteration Index

A parameter named iteration is not an item. It receives the Iteration output of the zone, which counts from 0.

@repeat_zone
def stack(geometry: Geometry, iteration: int):
    layer = transform_geometry(geometry=cube().mesh, translation=(0.0, 0.0, iteration * 1.5)).geometry
    return join_geometry(geometry=[geometry, layer]).geometry

@tree("Tower")
def tower(floors: int = 5):
    return stack(cube().mesh, iterations=floors)

Simulation Zones

Using a Simulation Input and Simulation Output node, you can create effects that change over time.

The @simulation_zone decorator creates these linked nodes. Like a repeat zone, its parameters are the simulation’s state items, and the function returns their new values in the same order.

@simulation_zone
def fall(geometry: Geometry, speed: float, delta_time: float):
    moved = set_position(geometry=geometry, offset=(0.0, 0.0, -speed * delta_time)).geometry
    return moved, speed + 9.8 * delta_time

@tree("Falling")
def falling(geometry: Geometry):
    return fall(geometry, 0.0).geometry

A parameter named delta_time is not a state item. It receives the time since the previous frame, in seconds.

Each frame, the result from the previous frame is passed into the zone’s inputs. The initial call to fall in falling provides the initial values for the simulation.

Skipping Steps

Pass skip= to set the Skip input of the Simulation Output node. When it is true, the simulation keeps its previous state for that frame.

paused = scene_time().frame > 50.0
fall(geometry, 0.0, skip=paused)

Animation without a Simulation

If a value only depends on the current frame, not on the previous state, you don’t need a simulation zone. Use the Scene Time node:

frame = scene_time().frame
seconds = scene_time().seconds

For Each Element Zones

A For Each Geometry Element zone runs a block of nodes once for each element of a geometry, such as each point or each face.

Use the @for_each_element decorator, and choose the domain with domain=:

@for_each_element(domain="POINT")
def ball_per_point(index: int, radius: float):
    ball = uv_sphere(radius=radius).mesh
    return ForEachResult(
        main={"scale": radius * 2.0},
        generated=[{"geometry": ball, "id": index}],
    )

@tree("Balls")
def balls(geometry: Geometry):
    points = mesh_to_points(mesh=geometry).points
    radius = random_value(min=0.05, max=0.2).value
    result = ball_per_point(points, radius=radius)
    return result.generated.geometry

When calling the zone, pass the geometry first. Every other parameter is an input of the zone, evaluated as a field on the geometry, so each element receives its own value. You can also pass selection= to only run the zone for some elements.

Two parameter names are special and are not inputs:

  • index receives the index of the current element.
  • element receives the current element as its own geometry.

Results

The zone function returns a ForEachResult:

  • main values are stored as attributes on the input geometry, one value per element.
  • generated is a list of groups. Each group starts with a geometry, followed by attributes stored on it. The geometry from every element is joined.

The zone’s outputs are split the same way. result.main.geometry is the input geometry with the main attributes, readable as result.main.scale. result.generated.geometry is the joined generated geometry.

If you only need main values, return a dictionary instead of a ForEachResult.

Closure Zones

A Closure zone packages a block of nodes as a value. The closure can be passed around, through node groups or bundles, and evaluated later with an Evaluate Closure node.

Use the @closure_zone decorator. The function’s parameters become the closure’s inputs, and its return values become the closure’s outputs.

@closure_zone
def scale(x: float, factor: float = 2.0):
    return x * factor

@tree("Closure")
def closure_tree(value: float):
    closure = scale()                          # A Closure socket
    return scale.evaluate(closure, value, 3.0) # Evaluate it

Calling the decorated function inside a tree creates the zone and returns its Closure socket. scale.evaluate(closure, ...) adds an Evaluate Closure node with the same inputs and outputs as scale, and passes the remaining arguments to its inputs.

The closure must be created before it is evaluated, so the zone’s outputs are known.

A tree can take a closure as an input with the Closure annotation:

@tree("Apply")
def apply(value: float, operation: Closure):
    return scale.evaluate(operation, value, 2.0)

Shaders and Compositing

Geometry Script can also build shader and compositor node trees. Each tree type has its own module, because each accepts a different set of nodes:

ModuleDecoratorsBuilds
geometry_script@tree (or @geometry)Geometry Nodes groups
geometry_script.shader@shader, @material, @worldShader node groups, materials and worlds
geometry_script.compositor@compositorCompositor node groups

Each module has every node its tree type accepts as a typed function, plus the same types (Color, Vector, Shader, …) and Socket arithmetic as the main module. Some names exist in several modules with different nodes behind them. For example, normal() is the Normal input in Geometry Nodes, but the Normal vector node in shaders. Import each module where it’s used. If a node is used in the wrong kind of tree, the error says which module it belongs to.

from geometry_script.shader import *   # A shader-only script
from geometry_script import *          # Geometry nodes
from geometry_script import shader as sh

sh.principled_bsdf(...)                # Shader nodes, behind a namespace

Materials

@material builds a material from a function with no parameters. Return the surface shader:

from geometry_script.shader import *

@material("Gold")
def gold():
    return principled_bsdf(
        base_color=(1.0, 0.77, 0.34, 1.0),
        metallic=1.0,
        roughness=0.2,
    ).bsdf

To use the other inputs of the Material Output, return a dictionary:

@material("Rough Stone")
def rough_stone():
    height = noise_texture(scale=8.0).factor
    return {
        "surface": principled_bsdf(base_color=(0.4, 0.4, 0.38, 1.0)).bsdf,
        "displacement": displacement(height=height, scale=0.05).displacement,
    }

The material is rebuilt in place each time the script runs, so objects that use it keep it. Calling the decorated function returns the material, so a geometry tree can assign it:

@tree("Golden Cube")
def golden_cube():
    return set_material(geometry=cube().mesh, material=gold()).geometry

Shader Node Groups

@shader builds a reusable shader node group. It works like @tree: parameters become group inputs, return values become group outputs, and calling it while building a material, world or another shader group adds a group node.

@shader("Stripes")
def stripes(color_a: Color, color_b: Color, scale: float = 6.0):
    wave = wave_texture(scale=scale)
    return mix(data_type="RGBA", factor=wave.factor, a=color_a, b=color_b).result

@material("Striped")
def striped():
    color = stripes((0.9, 0.3, 0.1, 1.0), (0.1, 0.1, 0.1, 1.0), scale=10.0)
    return principled_bsdf(base_color=color).bsdf

Materials and worlds have no inputs of their own, so put anything you’d like to adjust in Blender’s UI into a @shader group.

@repeat_zone and @closure_zone work in shader trees too.

Worlds

@world builds a world the same way. Pass scene=True to make it the active scene’s world:

@world("Studio", scene=True)
def studio():
    return background(color=(0.05, 0.06, 0.08, 1.0), strength=1.0).background

Compositing

@compositor builds a compositor node group from geometry_script.compositor. Pass scene=True to use it as the active scene’s compositor. Its first output is the final image:

from geometry_script.compositor import *

@compositor("Soft Glow", scene=True)
def soft_glow(strength: float = 0.3):
    image = render_layers().image
    glow = blur(image=image, size=(20, 20)).image
    return mix(data_type="RGBA", blend_type="SCREEN", factor=strength, a=image, b=glow).result

Without scene=True, the group can be used as a node in other compositor groups, just like a @tree inside another @tree.

Voxelize

This tutorial walks you through creating a script that turns any mesh into voxels.

Setting Up

Create a base mesh. I’ll be using a Monkey primitive.

Next, create a new script. Setting up an external editor is recommended.

Import Geometry Script, and create a basic tree builder function. We’ll add a geometry argument and annotate it with the Geometry type to receive our base mesh (in this case, a monkey).

from geometry_script import *

@tree("Voxelize")
def voxelize(geometry: Geometry):
    return geometry

Run the script to create the tree, then add a Geometry Nodes modifier to your mesh and select the Voxelize node group.

Arguments

Add a new argument resolution: float. Give it a default value of 0.2. This value will be used throughout the script to configure spacing and voxel density.

def voxelize(geometry: Geometry, resolution: float = 0.2):
    ...

Mesh to Volume

We want to convert the mesh to a volume, so we can fill its inside with points.

Use the mesh_to_volume function on the base mesh to convert it to a volume.

def voxelize(geometry: Geometry, resolution: float = 0.2):
    volume = mesh_to_volume(mesh=geometry, interior_band_width=resolution).volume
    return volume

Volume to Points

Next, we need to create points to instance each voxel cube on. Use distribute_points_in_volume with the mode set to Grid to create a uniform distribution of points. Its spacing is a vector, so we pass resolution for each axis.

def voxelize(geometry: Geometry, resolution: float = 0.2):
    volume = mesh_to_volume(mesh=geometry, interior_band_width=resolution).volume
    points = distribute_points_in_volume( # Uniform grid distribution
        volume=volume,
        mode="Grid",
        spacing=(resolution, resolution, resolution),
    ).points
    return points

Instance Cubes

Finally, use instance_on_points with a cube of size resolution to instance a cube on each point created from our mesh.

def voxelize(geometry: Geometry, resolution: float = 0.2):
    ...
    voxel = cube(size=(resolution, resolution, resolution)).mesh
    return instance_on_points(points=points, instance=voxel).instances # Cube instancing

You can lower the resolution to get smaller, more detailed voxels, or raise it to get larger voxels.

Final Script

from geometry_script import *

@tree("Voxelize")
def voxelize(geometry: Geometry, resolution: float = 0.2):
    volume = mesh_to_volume(mesh=geometry, interior_band_width=resolution).volume
    points = distribute_points_in_volume(
        volume=volume,
        mode="Grid",
        spacing=(resolution, resolution, resolution),
    ).points
    voxel = cube(size=(resolution, resolution, resolution)).mesh
    return instance_on_points(points=points, instance=voxel).instances

Generated Node Tree

City Builder

In this tutorial we’ll create a dense grid of buildings, then cut away from them to place roads with curves. We’ll then join the buildings and the roads into a single geometry.

Setting Up

Create a Bezier Curve object. You can enter edit mode and delete the default curve it creates.

Then create a new script. Setting up an external editor is recommended.

Import Geometry Script, and create a basic tree builder function. We’ll add a few arguments to configure the buildings.

from geometry_script import *

@tree("City Builder")
def city_builder(
    geometry: Geometry,
    building_size_min: Vector = (0.1, 0.1, 0.2),
    building_size_max: Vector = (0.3, 0.3, 1.0),
    size_x: float = 5.0,
    size_y: float = 5.0,
    road_width: float = 0.25,
    seed: int = 0,
    resolution: int = 60,
):
    return geometry

Run the script to create the tree, then add a Geometry Nodes modifier to your curve object and select the City Builder node group.

Buildings

Let’s start with the buildings. We’ll distribute points on a grid with size_x and size_y.

def city_builder(...):
    building_points = distribute_points_on_faces(
        mesh=grid(
            size_x=size_x, size_y=size_y,
            vertices_x=resolution, vertices_y=resolution,
        ).mesh,
        seed=seed,
    ).points
    return building_points

Next, we’ll instance cubes on these points to serve as our buildings. We move the cube up half its height so the buildings sit flat on the grid, and scale them randomly between the min and max sizes. Passing the point’s id to Random Value gives every building a different size.

def city_builder(...):
    ...
    building = transform_geometry(
        geometry=cube(size=(1.0, 1.0, 1.0)).mesh,
        translation=(0.0, 0.0, 0.5),
    ).geometry
    random_scale = random_value(
        min=building_size_min,
        max=building_size_max,
        seed=seed,
        id=id_().id,
    ).value
    buildings = instance_on_points(
        points=building_points,
        instance=building,
        scale=random_scale,
    ).instances
    return buildings

Roads

Using curve_to_mesh, we can turn the input curve into a flat mesh, by sweeping a short line along it. A tuple containing sockets, like (road_width * -0.5, 0.0, 0.0), is combined into a vector for us.

def city_builder(...):
    roads = curve_to_mesh(
        curve=geometry,
        profile_curve=curve_line(
            start=(road_width * -0.5, 0.0, 0.0),
            end=(road_width * 0.5, 0.0, 0.0),
        ).curve,
    ).mesh
    ...

To output both the roads and the buildings, join them with join_geometry:

def city_builder(...):
    ...
    return join_geometry(geometry=[roads, buildings]).geometry

But now the buildings are overlapping the road. We need to remove any point that falls within the road curve. We’ll use geometry_proximity to measure the distance from each building to the nearest road point, compare to check it against the road width, and delete_geometry to remove the buildings that are too close.

def city_builder(...):
    road_points = curve_to_points(curve=geometry, mode="EVALUATED").points
    ...
    building_points = ...
    road_distance = geometry_proximity(
        geometry=road_points,
        target_element="POINTS",
        sample_position=position().position,
    ).distance
    building_points = delete_geometry(
        geometry=building_points,
        domain="POINT",
        selection=compare(a=road_distance, b=road_width * 2, operation="LESS_THAN").result,
    ).geometry
    ...

Drawing Roads

Enter edit mode and select the Draw tool. Simply draw roads onto your city to see the buildings and meshes update.

Final Script

from geometry_script import *

@tree("City Builder")
def city_builder(
    geometry: Geometry,
    building_size_min: Vector = (0.1, 0.1, 0.2),
    building_size_max: Vector = (0.3, 0.3, 1.0),
    size_x: float = 5.0,
    size_y: float = 5.0,
    road_width: float = 0.25,
    seed: int = 0,
    resolution: int = 60,
):
    # Road geometry from input curves
    road_points = curve_to_points(curve=geometry, mode="EVALUATED").points
    roads = curve_to_mesh(
        curve=geometry,
        profile_curve=curve_line(
            start=(road_width * -0.5, 0.0, 0.0),
            end=(road_width * 0.5, 0.0, 0.0),
        ).curve,
    ).mesh

    # Randomly distribute buildings on a grid
    building_points = distribute_points_on_faces(
        mesh=grid(
            size_x=size_x, size_y=size_y,
            vertices_x=resolution, vertices_y=resolution,
        ).mesh,
        seed=seed,
    ).points

    # Delete building points that are too close to a road
    road_distance = geometry_proximity(
        geometry=road_points,
        target_element="POINTS",
        sample_position=position().position,
    ).distance
    building_points = delete_geometry(
        geometry=building_points,
        domain="POINT",
        selection=compare(a=road_distance, b=road_width * 2, operation="LESS_THAN").result,
    ).geometry

    # Instance a randomly scaled cube on each remaining point
    building = transform_geometry(
        geometry=cube(size=(1.0, 1.0, 1.0)).mesh,
        translation=(0.0, 0.0, 0.5),
    ).geometry
    random_scale = random_value(
        min=building_size_min,
        max=building_size_max,
        seed=seed,
        id=id_().id,
    ).value
    buildings = instance_on_points(
        points=building_points,
        instance=building,
        scale=random_scale,
    ).instances

    return join_geometry(geometry=[roads, buildings]).geometry

Generated Node Tree