Introduction

Geometry Script is a scripting API for Blender's Geometry Nodes. It makes complicated node trees more managable 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, height: Int):
    g = grid(
        size_x=width, size_y=height,
        vertices_x=width, vertices_y=height
    ).mesh_to_points()
    return g.instance_on_points(instance=geometry)

Generated Node Tree

Generated node tree

Installation

The add-on is available on GitHub and Blender Market. Choose where you want to get it from and follow the steps below:

From GitHub

  1. Download the source code
  2. Open Blender > Preferences > Add-ons
  3. Choose Install... and select the downloaded ZIP file

From Blender Market

  1. After purchasing the add-on, download the ZIP file
  2. Open Blender > Preferences > Add-ons
  3. Choose Install... and select the downloaded ZIP file

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.

A screenshot of the available spaces, with the Text Editor space highlighted

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

A screenshot of the Text Editor space 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, height: Int):
    g = grid(
        size_x=width, size_y=height,
        vertices_x=width, vertices_y=height
    ).mesh_to_points()
    return g.instance_on_points(instance=geometry)
  1. Click the run button to execute the script. This will create a Geometry Nodes tree named Repeat Grid.

A screenshot of the Text Editor space with the Run Script button

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

A screenshot of the Blender window with a 3x3 grid of cubes on the left and a Geometry Nodes modifier with the Repeat Grid tree selected on the right

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

When the Geometry Script add-on starts, it generates a Python typeshed file that can be used to provide code completion. All we have to do is add the right path to the Python extension's configuration:

  1. Open Blender preferences and expand the Geometry Script preferences
  2. Copy the Typeshed Path

A screenshot of the Geometry Script preferences

  1. In VS Code, open the Settings UI (Shift+CTRL+P or Shift+CMD+P > Preferences > Open Settings (UI))
  2. Find the setting Python > Analysis: Extra Paths
  3. Click Add Item, then paste in the path copied from Blender and click OK

A screenshot of the Python > Analysis: Extra Paths setting with the path pasted in

  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.

A screenshot of a script with the documentation for instance_on_points appearing as the user types.

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.

A screenshot of Blender's file picker, with the Make Internal checkbox unchecked.

  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.

A screenshot of the Geometry Script menu with Auto Resolve checked

  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.

A screenshot of the Text menu with Live Edit checked

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.

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

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():
    ...

Group Output

Every node tree is required to return Geometry as the first output. Let's try returning a simple cube.

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

Here we call the cube(...) function, which creates a Cube node and connects it to the Group Output.

You can also return multiple values. However, Geometry must always be returned first for a tree to be valid.

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

By default, each output is named 'Result'. To customize the name, return a dictionary.

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

Group Input

All arguments in a tree function must be annotated with a valid socket type. These types are provided by Geometry Script, and are not equivalent to Python's built-in types. Let's add a size argument to our Cube Tree.

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

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. Here are some common ones:

  • Geometry
  • Float
  • Int
  • Vector

You cannot use Python's built-in types in place of these socket types.

In the next chapter, we'll take a closer look at how socket types 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, 1, 1)):
    return cube(size=size)

Sockets

Because scripts are converted to Geometry Node trees, you typically cannot use default Python types as arguments. In some cases, they will be automatically converted for you, but in general you will be dealing with socket types.

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 1 output socket.

  • Input Sockets
    • Size: Vector
    • Vertices X: Int
    • Vertices Y: Int
    • Vertices Z: Int
  • Output Sockets
    • Mesh: Geometry

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 socket types, not concrete values like (1, 1, 1). Take this script for example:

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

The size argument creates a 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 socket type like Geometry 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 type does not equal 5 or 10 or 3.14 to our script. It only represents the socket of a node. If you try to print(...) a socket, you will receive a generic reference type with no underlying value.

Why use sockets?

You might be wondering, "if you can't access the value of a socket, what can you do with it?"

Geometry Script provides many helpful additions that make working with sockets about as easy as working with a concrete value.

Socket Math

Socket types can be used to perform math operations. 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 Float or Int it will create a Math node, and if you use a Vector 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)

Several common math operations are available, such as:

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

Socket Comparison

Socket types can be compared with Python comparison operators. A Compare node will be created with the correct inputs and options specified.

@tree("Cube Tree")
def cube_tree(size: Vector):
    show_cube = size > (2, 2, 2) # Check if each component is greater than 2
    return cube(size=show_cube)

Several common comparison operators are supported, such as:

  • Equal To (socket == 2)
  • Not Equal To (socket != 2)
  • Less Than (socket < 2)
  • Less Than Or Equal To (socket <= 2)
  • Greater Than (socket > 2)
  • Greater Than Or Equal To (socket >= 2)

Vector Component Properties

While the Vector type does not equate to three concrete components, such as (1, 2, 3), you can still access the x, y, and z components as sockets. A Separate XYZ node will be created with the correct inputs and outputs specified.

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

For each component access, a Separate XYZ node is created.

Chained Calls

Any node function can be called on a socket type. This will automatically connect the socket to the first input of the node.

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

The output of the Cube node (a Geometry socket type) is connected to the first input of the Mesh to Volume node.

The same script without chaining calls is written more verbosely as:

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

Spanning Multiple Lines

Often times you want each chained calls to be on a separate line. There are a few ways to do this in Python:

  1. Newlines around arguments
cube(
    size=size
).mesh_to_volume()
  1. Parentheses
(cube(size=size)
 .mesh_to_volume())
  1. Line continuation
cube(size=size) \
    .mesh_to_volume()

Using Nodes

Node functions are automatically generated for the Blender version you are using. This means every node will be available from geometry script.

This means that when future versions of Blender add new nodes, they will all be available in Geometry Script without updating the add-on.

To see all of the node functions available in your Blender version, open the Geometry Script menu in the Text Editor and click Open Documentation.

This will open the automatically generated docs page with a list of every available node and it's inputs and outputs.

How nodes are mapped

All nodes are mapped to functions in the same way, so even without the documentation you can decifer what a node will equate to. Using an IDE with code completion makes this even easier.

The general process is:

  1. Convert the node name to snake case.
  2. Add a keyword argument (in snake case) for each property and input.
  3. If the node has a single output, return the socket type, otherwise return an object with properties for each output name.

Properties and inputs are different types of argument. 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. Check the documentation for a node to see what the possible values are for a property.

Enum Properties

Many nodes have enum properties. For example, the math node lets you choose which operation to perform. You can pass a string to specify the enum case to use. But a safer way to set these values is with the autogenerated enum types. The enums are namespaced to the name of the node in PascalCase:

# Access it by Node.Enum Name.Case
math(operation=Math.Operation.ADD)
math(operation=Math.Operation.SUBTRACT)
math(operation='MULTIPLY') # Or manually pass a string

Internally, this type is generated as:

import enum
class Math:
  class Operation(enum.Enum):
    ADD = 'ADD'
    SUBTRACT = 'SUBTRACT'
    MULTIPLY = 'MULTIPLY'
    ...
  ...

The cases will appear in code completion if you setup an external editor.

Duplicate Names

Some nodes use the same input name multiple times. For example, the Math node has three inputs named value. To specify each value, pass a tuple for the input:

math(operation=Math.Operation.WRAP, value=(0.5, 1, 0)) # Pass all 3
math(operation=Math.Operation.WRAP, value=(0.5, 1)) # Only pass 2/3
math(operation=Math.Operation.WRAP, value=0.5) # Only pass 1/3

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. Return Geometry

The node can now be used as a function:

cube() # All arguments are optional
cube(size=(1, 1, 1), vertices_x=3) # Optionally specify keyword arguments
cube_geo: Geometry = cube() # Returns a Geometry socket type

The generated documentation will show the signature, result type, and chain syntax.

Signature

cube(
  size: VectorTranslation,
  vertices_x: Int,
  vertices_y: Int,
  vertices_z: Int
)

Result

mesh: Geometry

Chain Syntax

size: VectorTranslation = ...
size.cube(...)

Capture Attribute

  1. Name Capture Attribute -> capture_attribute
  2. Keyword Arguments
    • Properties
      • data_type: CaptureAttribute.DataType
      • domain: CaptureAttribute.Domain
    • Inputs
      • geometry: Geometry
      • value: Vector | Float | Color | Bool | Int
  3. Return { geometry: Geometry, attribute: Int }

The node can now be used as a function:

result = capture_attribute(data_type=CaptureAttribute.DataType.BOOLEAN, geometry=cube_geo) # Specify a property and an input
result.geometry # Access the geometry
result.attribute # Access the attribute

The generated documentation will show the signature, result type, and chain syntax.

Signature

capture_attribute(
  data_type: CaptureAttribute.DataType,
  domain: CaptureAttribute.Domain,
  geometry: Geometry,
  value: Vector | Float | Color | Bool | Int
)

Result

{ geometry: Geometry, attribute: Int }

Chain Syntax

geometry: Geometry = ...
geometry.capture_attribute(...)

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 """
    return grid().mesh_to_points().instance_on_points(instance=instance)

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

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:

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. No checks are made against the arguments. Any nodes created in the callee will be placed in the caller's tree.

def instance_grid(instance: Geometry): # Not marked with `@tree(...)`
    return grid().mesh_to_points().instance_on_points(instance=instance)

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

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():
    return grid().mesh_to_points().instance_on_points(instance=cube(size=0.2))

Generators

Python has support for generators using the yield keyword.

Geometry Script tree functions can be represented as generators to output multiple values. If every generated value is Geometry, the values are automatically connected to a Join Geometry node and output as a single mesh.

@tree("Primitive Shapes")
def primitive_shapes():
    yield cube()
    yield uv_sphere()
    yield cylinder().mesh

However, if any of the outputs is not Geometry, separate sockets are created for each output.

@tree("Primitive Shapes and Integer")
def primitive_shapes():
    yield cube()
    yield uv_sphere()
    yield cylinder().mesh
    yield 5 # Not a geometry socket type

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

Input Groups

Some geometry node trees need a lot of arguments.

@tree("Terrain Generator")
def terrain_generator(
    width: Float
    height: Float
    resolution: Int
    scale: Float
    w: Float
):
    ...

There are a couple of problems with this. Firstly, the function signature is getting long. This can make it harder to visually parse the script. And, if we want to use the same arguments in another tree and pass them through to terrain, we need to make sure to keep everything up to date.

This is where input groups come in. An input group is class that contains properties annotated with valid socket types.

To create an input group, declare a new class that derives from InputGroup.

class TerrainInputs(InputGroup):
    width: Float
    height: Float
    resolution: Int
    scale: Float
    w: Float

Then annotate an argument in your tree function with this class.

@tree("Terrain Generator")
def terrain_generator(
    inputs: TerrainInputs
):
    ...

This will create a node tree with the exact same structure as the original implementation. The inputs can be accessed with dot notation.

size = combine_xyz(x=input.width, y=input.height)

And now passing the inputs through from another function is much simpler.

def point_terrain(
    terrain_inputs: TerrainInputs,
    radius: Float
):
    return terrain_generator(
        inputs=terrain_inputs
    ).mesh_to_points(radius=radius)

Instantiating Input Groups

If you nest calls to tree functions, you can instantiate the InputGroup subclass to pass the correct inputs.

def point_terrain():
    return terrain_generator(
        inputs=TerrainInputs(
            width=5,
            height=5,
            resolution=10,
            scale=1,
            w=0
        )
    ).mesh_to_points()

Input Group Prefix

If you use the same InputGroup multiple times, you need to provide a prefix. Otherwise, inputs with duplicate names will be created on your tree.

To do this, use square brackets next to the annotation with a string for the prefix.

def mountain_or_canyon(
    mountain_inputs: TerrainInputs["Mountain"], # Prefixed with 'Mountain'
    canyon_inputs: TerrainInputs["Canyon"], # Prefixed with 'Canyon'
    is_mountain: Bool
):
    return terrain_generator(
        inputs=switch(switch=is_mountain, true=mountain_inputs, false=canyon_inputs)
    )

Attributes

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

When using these methods, the data_type argument must be correctly specified for the transfer to work as intended.

@tree("Skin")
def skin():
    # Create a cube
    c = cube()
    # Create a sphere
    sphere = uv_sphere()
    # Transfer the position to the sphere
    transferred_position = c.transfer_attribute(
        data_type=TransferAttribute.DataType.FLOAT_VECTOR,
        attribute=position()
    )
    # Make the sphere conform to the shape of the cube
    return sphere.set_position(position=transferred_position)

To improve the usability of these nodes, capture(...) and transfer(...) methods are provided on Geometry that simply take the attribute and any other optional arguments.

@tree("Skin")
def skin():
    # Create a cube
    c = cube()
    # Create a sphere
    sphere = uv_sphere()
    # Make the sphere conform to the shape of the cube
    return sphere.set_position(position=c.transfer(position()))

The same is available for capture(...).

geometry_with_attribute, attribute = c.capture(position())

You must use the Geometry returned from capture(...) for the anonymous attribute it creates to be usable.

Any additional keyword arguments can be passed as normal.

c.transfer(position(), mapping=TransferAttribute.Mapping.INDEX)

Named Attributes

Custom attributes can be created by name. The safest way to use named attributes is with the Attribute class.

Create a named attribute with a data type and optional domain, then use the store(...), exists(), and __call__(...) methods to use it.

# Create the attribute
my_custom_attribute = Attribute(
    "my_custom_attribute",
    NamedAttribute.DataType.FLOAT, # declare the data type once
    StoreNamedAttribute.Domain.INSTANCE # optional
)
# Store a value
geometry = my_custom_attribute.store(geometry, 0.5)
# Use the value by calling the attribute
geometry = geometry.set_position(offset=my_custom_attribute())

Attribute Sampling

In Blender 3.4+, transfer attribute was replaced with a few separate nodes: Sample Index, Sample Nearest, and Sample Nearest Surface.

To avoid inputting data types and geometry manually, you can use the custom Geometry subscript.

The structure for these subscripts is:

geometry[value : index or sample position : domain, mode, domain]

Only the value argument is required. Other arguments can be supplied as needed.

geometry[value]
geometry[value : sample_position, SampleMode.NEAREST]
geometry[value : index() + 1 : SampleIndex.Domain.EDGE]

Try passing different arguments and see how the resulting nodes are created.

Boolean Math

The Boolean Math node gives access to common boolean operations, such as AND, NOT, XOR, etc.

However, it can be cumbersome to use the boolean_math function in complex boolean expressions.

# Check if the two values equal, or if the first is true.
x = False
y = True
return boolean_math(
    operation=BooleanMath.Operation.OR
    boolean=(
        boolean_math(
            operation=BooleanMath.Operation.XNOR # Equal
            boolean=(x, y)
        ),
        x
    )
)

A few operators are available to make boolean math easier and more readable.

# Check if the two values equal, or if the first is true.
x = False
y = True
return (x == y) | x

The operators available are:

  • == - XNOR
  • != - XOR
  • | - OR
  • & - AND
  • ~ - NOT

You cannot use the built-in Python keywords and, or, and not. You must use the custom operators above to create Boolean Math nodes.

Curves

Some nodes, such as Float Curve take a curve as a property. You can create a curve with the Curve class.

float_curve(
    mapping=Curve(
        Point(0, 0),
        Point(0.5, 0.25),
        Point(1, 1, HandleType.VECTOR), # Optionally specify a handle type, such as `AUTO`, `VECTOR`, or `AUTO_CLAMPED`.
    )
)

You can also pass the points as a list to Curve.

points = [Point(0, 0), Point(1, 1)]
float_curve(
    mapping=Curve(points)
)

If a node has multiple curve properties, such as the Vector Curves node, pass a list of curves to the node.

vector_curves(
    mapping=[x_curve, y_curve, z_curve]
)

Drivers

Drivers can be used with geometry nodes. To create a scripted expression driver, use the scripted_expression convenience function.

This can be used to get information like the current frame number in a Geometry Script.

frame_number = scripted_expression("frame")
frame_number_doubled = scripted_expression("frame * 2")

Simulation Zones

Blender 3.6 includes simulation nodes.

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

As a convenience, the @simulation_zone decorator is provided to make simulation node blocks easier to create.

from geometry_script import *

@tree
def test_sim(geometry: Geometry):
    @simulation_zone
    def my_sim(delta_time, geometry: Geometry, value: Float):
        return (geometry, value)
    return my_sim(geometry, 0.26).value

The first argument should always be delta_time. Any other arguments must also be returned as a tuple with their modified values. Each frame, the result from the previous frame is passed into the zone's inputs. The initial call to my_sim in test_sim provides the initial values for the simulation.

Blender 4.0+

A "Skip" argument was added to the Simulation Output node in Blender 4.0.

Return a boolean value first from any simulation zone to determine whether the step should be skipped.

The simplest way to migrate existing node trees is by adding False to the return tuple.

@simulation_zone
def my_sim(delta_time, geometry: Geometry, value: Float):
    return (False, geometry, value)

You can pass any boolean value as the skip output.

Repeat Zones

Blender 4.0 introduced repeat zones.

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

You must use the @repeat_zone decorator to create these special linked nodes.

from geometry_script import *

@tree
def test_loop(geometry: Geometry):
    @repeat_zone
    def doubler(value: Float):
        return value * 2
    return points(count=doubler(5, 1)) # double the input value 5 times.

The function should modify the input values and return them in the same order.

When calling the repeat zone, pass the Iterations argument first, then any other arguments the function accepts.

For example:

def doubler(value: Float) -> Float

would be called as:

doubler(iteration_count, value)

When a repeat zone has multiple arguments, return a tuple from the zone.

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

Voxelize

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

This tutorial requires Blender 3.4+ for the Distribute Points In Volume node.

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 hollow volume, so only the outside of the mesh has voxel instances. This will improve the performance of our script.

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

def voxelize(geometry: Geometry, resolution: Float = 0.2):
    return geometry.mesh_to_volume( # Hollow mesh volume
        interior_band_width=resolution,
        fill_volume=False
    )

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 DENSITY_GRID to create a uniform distribution of points.

def voxelize(geometry: Geometry, resolution: Float = 0.2):
    return geometry.mesh_to_volume(
        interior_band_width=resolution,
        fill_volume=False
    ).distribute_points_in_volume( # Uniform grid distribution
        mode=DistributePointsInVolume.Mode.DENSITY_GRID,
        spacing=resolution
    )

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):
    return geometry.mesh_to_volume(
        interior_band_width=resolution,
        fill_volume=False
    ).distribute_points_in_volume(
        mode=DistributePointsInVolume.Mode.DENSITY_GRID,
        spacing=resolution
    ).instance_on_points( # Cube instancing
        instance=cube(size=resolution)
    )

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

Final Script

# NOTE: This example requires Blender 3.4+

from geometry_script import *

@tree("Voxelize")
def voxelize(geometry: Geometry, resolution: Float = 0.2):
    return geometry.mesh_to_volume(
        interior_band_width=resolution,
        fill_volume=False
    ).distribute_points_in_volume(
        mode=DistributePointsInVolume.Mode.DENSITY_GRID,
        spacing=resolution
    ).instance_on_points(
        instance=cube(size=resolution)
    )

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 also make use of a generator to combine the buildings with the roads.

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,
    seed: Int,
    road_width: Float = 0.25,
    size_x: Float = 5, size_y: Float = 5, density: Float = 10,
    building_size_min: Vector = (0.1, 0.1, 0.2),
    building_size_max: Vector = (0.3, 0.3, 1),
):
    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 = grid(size_x=size_x, size_y=size_y).distribute_points_on_faces(density=density, seed=seed).points
    return building_points

Next, we'll instance cubes on these points to serve as our buildings. We move the cube object up half its height so the buildings sit flat on the grid, and scale them randomly between the min and max sizes.

def city_builder(...):
    ...
    return building_points.instance_on_points(
        instance=cube().transform(translation=(0, 0, 0.5)),
        scale=random_value(data_type=RandomValue.DataType.FLOAT_VECTOR, min=building_size_min, max=building_size_max, seed=seed),
    )

Roads

Using curve_to_mesh, we can turn the input curve into a flat mesh. We'll use the yield keyword to join the curve mesh and the building mesh automatically. Change the building_points.instance_on_points line to use yield for this to work.

def city_builder(...):
    yield geometry.curve_to_mesh(profile_curve=curve_line(
        start=combine_xyz(x=road_width * -0.5),
        end=combine_xyz(x=road_width * 0.5)
    ))
    ...
    yield building_points.instance_on_points(...)

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 and delete_geometry to find and remove these invalid points.

def city_builder(...):
    ...
    building_points = ...
    road_points = geometry.curve_to_points(mode=CurveToPoints.Mode.EVALUATED).points
    building_points = building_points.delete_geometry(
        domain=DeleteGeometry.Domain.POINT,
        selection=geometry_proximity(target_element=GeometryProximity.TargetElement.POINTS, target=road_points, source_position=position()).distance < road_width
    )
    ...

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,
    seed: Int,
    road_width: Float = 0.25,
    size_x: Float = 5, size_y: Float = 5, density: Float = 10,
    building_size_min: Vector = (0.1, 0.1, 0.2),
    building_size_max: Vector = (0.3, 0.3, 1),
):
    # Road mesh
    yield geometry.curve_to_mesh(profile_curve=curve_line(
        start=combine_xyz(x=road_width * -0.5),
        end=combine_xyz(x=road_width * 0.5)
    ))
    # Building points
    building_points = grid(size_x=size_x, size_y=size_y).distribute_points_on_faces(density=density, seed=seed).points
    road_points = geometry.curve_to_points(mode=CurveToPoints.Mode.EVALUATED).points
    # Delete points within the curve
    building_points = building_points.delete_geometry(
        domain=DeleteGeometry.Domain.POINT,
        selection=geometry_proximity(target_element=GeometryProximity.TargetElement.POINTS, target=road_points, source_position=position()).distance < road_width
    )
    # Building instances
    yield building_points.instance_on_points(
        instance=cube().transform(translation=(0, 0, 0.5)),
        scale=random_value(data_type=RandomValue.DataType.FLOAT_VECTOR, min=building_size_min, max=building_size_max, seed=seed),
    )

Generated Node Tree