from nodebpy import geometry as g
from nodebpy.builder import CustomGeometryGroup
from nodebpy.types import (
InputFloat,
InputInteger,
InputVector,
InputGeometry,
)Custom Node Groups
Custom node groups let you encapsulate reusable logic into a single node. By subclassing CustomGeometryGroup, you define a Python class that:
- Names the group with
_name. - Exposes its inputs through an ordinary
__init__. - Implements the internal graph in a
_build_groupmethod.
The result works like any other node – full IDE autocomplete, type hints, >> chaining, and operator support. The group’s node tree is built once on first use and cached in bpy.data.node_groups, so creating many instances of the same group is cheap.
Anatomy of a Custom Node Group
class MyNode(CustomGeometryGroup):
_name = "My Node" # 1. Name
def __init__(self, value: float = 0.0): # 2. Constructor
super().__init__(value=value)
def _build_group(self, tree): # 3. Graph logic
value = tree.inputs.float("Value")
_ = value + 1.0 >> tree.outputs.float("Result")1. _name
The display name for the group inside Blender. This is also the key used to cache it in bpy.data.node_groups.
A First Example: Jitter
Let’s start with something visually obvious – a node that randomly displaces each point on a mesh. This is useful any time you want to add organic variation to geometry.
class Jitter(CustomGeometryGroup):
"""Randomly offset each point by a bounded amount."""
_name = "Jitter"
_color_tag = "GEOMETRY"
def __init__(
self,
geometry: InputGeometry = ...,
amount: InputFloat = 0.2,
seed: InputInteger = 0,
):
super().__init__(**{"Geometry": geometry, "Amount": amount, "Seed": seed})
def _build_group(self, tree):
geometry = tree.inputs.geometry("Geometry")
amount = tree.inputs.float("Amount", 0.2)
seed = tree.inputs.integer("Seed")
offset = g.RandomValue.vector(min=-1, seed=seed) * amount
result = geometry >> g.SetPosition(offset=offset)
_ = result >> tree.outputs.geometry()Let’s see the internal node graph:
with g.tree("JitterInternal") as tree:
_ = Jitter()
treeNow use it like any built-in node – apply it to an ico sphere:
with g.tree("JitterDemo") as tree:
out = tree.outputs.geometry()
_ = g.IcoSphere(subdivisions=4) >> Jitter(amount=0.15) >> out
treeRadial Array
Next, a node that distributes instances in a ring. This is a common pattern for creating wheels, flower petals, gears, and other radially symmetric objects.
from math import tau
class RadialArray(CustomGeometryGroup):
"""Distribute instances evenly around a circle."""
_name = "Radial Array"
_color_tag = "GEOMETRY"
def __init__(
self,
geometry: InputGeometry = ...,
count: InputInteger = 6,
radius: InputFloat = 2.0,
):
super().__init__(geometry=geometry, count=count, radius=radius)
def _build_group(self, tree):
geometry = tree.inputs.geometry()
count = tree.inputs.integer("Count", 6)
radius = tree.inputs.float("Radius", 2.0)
# Create points arranged in a circle
angle = g.Index() * tau / count
circle_pos = g.CombineXYZ(
x=g.Math.cosine(angle) * radius, y=g.Math.sine(angle) * radius
)
# Place instances at each point, rotated to face outward
rotation = g.CombineXYZ(z=angle)
result = (
g.Points(count)
>> g.SetPosition(position=circle_pos)
>> g.InstanceOnPoints(instance=geometry, rotation=rotation)
)
_ = result >> tree.outputs.geometry()with g.tree("RadialInternal") as tree:
_ = RadialArray()
treewith g.tree("RadialDemo") as tree:
petal = g.Cone(vertices=4, radius_bottom=0.3, depth=0.8)
_ = (
petal
>> RadialArray(count=8, radius=2.0)
>> g.RealizeInstances()
>> tree.outputs.geometry()
)
treeComposing Groups: Jittered Flower
Custom groups compose naturally – they chain with >>, accept each other’s outputs, and mix with operators just like built-in nodes. Let’s combine Jitter and RadialArray to build a flower-like structure with some organic randomness.
with g.tree("JitteredFlower") as tree:
petals = tree.inputs.integer("Petals", 20, min_value=3, max_value=24)
radius = tree.inputs.float("Radius", 0.5, min_value=0.1)
jitter = tree.inputs.float("Jitter", 0.1)
out = tree.outputs.geometry()
PETAL_LENGTH = 0.8
petal = g.Cube(size=(PETAL_LENGTH, 0.2, 0.1)) >> g.TransformGeometry(
translation=(PETAL_LENGTH / 4, 0, 0)
)
ring = (
petal
>> RadialArray(count=petals, radius=radius)
>> Jitter(amount=jitter)
>> g.RotateInstances(rotation=(0, -tau / 16, 0))
)
center = g.IcoSphere(radius=0.4, subdivisions=3)
_ = g.JoinGeometry([ring, center]) >> g.RealizeInstances() >> out
treeEach custom group appears as a single, named node in the tree – keeping the graph readable even as the logic grows.
Fallback Inputs
A group input can read an implicit field or a context value when nothing is connected to it — what Blender’s interface editor calls the Default Input. The tree.inputs.* factories take it as default_input=, accepting either Blender’s identifier strings or the members of nodebpy.Default. A Default Attribute (default_attribute=) is different: it only applies when the group is used directly as a Geometry Nodes modifier, where the input starts out reading that attribute. As a node inside another tree the input uses its value.
from nodebpy import Default
class Displace(CustomGeometryGroup):
"""Push points along a noise pattern sampled from the UV map."""
_name = "Displace"
def __init__(
self,
geometry: InputGeometry = ...,
position: InputVector = Default.POSITION,
uv_map: InputVector = None,
strength: InputFloat = 0.1,
):
super().__init__(
**{
"Geometry": geometry,
"Position": position,
"UV Map": uv_map,
"Strength": strength,
}
)
def _build_group(self, tree):
geometry = tree.inputs.geometry("Geometry")
position = tree.inputs.vector("Position", default_input=Default.POSITION)
uv_map = tree.inputs.vector("UV Map", default_attribute="UVMap")
strength = tree.inputs.float("Strength", 0.1)
offset = g.NoiseTexture(vector=uv_map).o.color * strength
result = g.SetPosition(geometry, position=position, offset=offset)
_ = result >> tree.outputs.geometry()
with g.tree("DisplaceInternal") as tree:
_ = Displace()
treeThe constructor mirrors the interface: Default.POSITION as a parameter default documents the fallback, and passing it through leaves the group node’s socket untouched so the fallback applies.
Class Options
CustomGeometryGroup (via its NodeGroupBuilder base class) supports a few class-level options:
| Attribute | Type | Default | Description |
|---|---|---|---|
_name |
str |
(required) | Display name and cache key for the group |
_color_tag |
str |
"NONE" |
Header colour in Blender ("INPUT", "CONVERTER", "GEOMETRY", etc.) |
_warning_propagation |
str |
"ALL" |
How warnings propagate ("ALL", "ERRORS_AND_WARNINGS", "ERRORS", "NONE") |