Every nodebpy tree can be written back out as Python source. TreeBuilder.to_python() (or the standalone nodebpy.export.to_python()) inspects a node tree and generates readable nodebpy code that recreates it — including the interface sockets, node properties, links, zones and frames.
This works on any node tree, not just ones built with nodebpy. A tree you wired up by hand in the Blender UI, appended from an asset library, or created with raw bpy calls can all be converted, making it easy to migrate existing node setups into version-controlled Python code.
Simple Example
Here we build a tree with the raw bpy API, standing in for a tree that already exists in a .blend file:
from nodebpy.export import to_pythoncode = to_python(nt)
from nodebpy import geometry as g, TreeBuilderwith TreeBuilder("Scatter") as tree: geometry = tree.inputs.geometry("Geometry") count = tree.inputs.integer("Count", 100) geometry_1 = tree.outputs.geometry("Geometry") ( geometry>> g.DistributePointsOnFaces(density=count)>> g.InstanceOnPoints(instance=g.IcoSphere())>> g.RealizeInstances()>> geometry_1 )
Twenty lines of bpy plumbing become a single >> pipeline. The generated source is self-contained — running it recreates the tree.
Complex Example
This is a new node group asset in Blender 5.2 to do PCA on the positions of points. Two examples of this node group below, with the generated and the human-curated / written versions below. More through and elegance can be put into the human-written version, but the generated version can be improved with time.
from nodebpy import geometry as g, TreeBuilderwith TreeBuilder("Principal Components") as tree: position = tree.inputs.vector("Position", (0.0, 0.0, 0.0), default_input="POSITION") group_id = tree.inputs.integer("Group ID",0, description="An index used to group values together for multiple separate operations", hide_value=True, ) group_center = tree.outputs.vector("Group Center") principal_components = tree.outputs.vector("Principal Components", description="Variance of the data along each principal axis", ) rotation = tree.outputs.rotation("Rotation", description="Rotation that defines the principal component basis" )with tree.outputs.panel("Principal Axes", default_closed=True): longest_axis = tree.outputs.vector("Longest Axis") intermediate_axis = tree.outputs.vector("Intermediate Axis") shortest_axis = tree.outputs.vector("Shortest Axis")with g.Frame("Centroid"): field_average = position.point.mean(group_id)with g.Frame("Covariance Matrix"): vector_math = position - field_average mean = (vector_math * vector_math.x).point.mean(group_id) mean_1 = (vector_math * vector_math.y).point.mean(group_id) mean_2 = (vector_math * vector_math.z).point.mean(group_id) combine_matrix = g.CombineMatrix( column_1_row_1=mean.x, column_1_row_2=mean.y, column_1_row_3=mean.z, column_2_row_1=mean_1.x, column_2_row_2=mean_1.y, column_2_row_3=mean_1.z, column_3_row_1=mean_2.x, column_3_row_2=mean_2.y, column_3_row_3=mean_2.z, )with g.Frame("SVD"): matrix_svd = combine_matrix.o.matrix.svd() separate_matrix = g.SeparateMatrix(matrix=matrix_svd.u) combine_xyz = g.CombineXYZ( x=separate_matrix, y=separate_matrix.o.column_1_row_2, z=separate_matrix.o.column_1_row_3, ) combine_xyz_1 = g.CombineXYZ( x=separate_matrix.o.column_2_row_1, y=separate_matrix.o.column_2_row_2, z=separate_matrix.o.column_2_row_3, ) combine_xyz_2 = g.CombineXYZ( x=separate_matrix.o.column_3_row_1, y=separate_matrix.o.column_3_row_2, z=separate_matrix.o.column_3_row_3, ) axes_to_rotation = g.AxesToRotation( primary_axis=combine_xyz, secondary_axis=combine_xyz_2 ) combine_xyz_1.o.vector * matrix_svd.u.determinant().sign() >> intermediate_axis field_average >> group_center matrix_svd.s >> principal_components axes_to_rotation >> rotation combine_xyz >> longest_axis combine_xyz_2 >> shortest_axis
from nodebpy import geometry as gwith g.tree(): position = tree.inputs.vector("Position", default_input="POSITION") group_id = tree.inputs.integer("Group ID", description="An index used to group values together for multiple separate operations", hide_value=True ) out_centroid = tree.outputs.vector("Centroid") out_princ = tree.outputs.vector("Principal Components", description="Variance of the data along each principal axis", ) out_rotation = tree.outputs.rotation("Rotation", description="Rotation that defines the principal component basis", )with tree.outputs.panel("Principal Axes", default_closed=True): out_long = tree.outputs.vector("Longest Axis") out_inter = tree.outputs.vector("Intermediate Axis") out_short = tree.outputs.vector("Shortest Axis")with g.Frame("Centroid"): centroid = position.point.mean(group_id) centroid >> out_centroidwith g.Frame("Covariance Matrix"): diff = position - centroid matrix = g.CombineMatrix()for i, axis1 inenumerate(diff): mean = (diff * axis1).point.mean(group_id)for j, axis2 inenumerate(mean): axis2 >> matrix.i[int(i *4+ j)]with g.Frame("SVD"): u, s, v = matrix.o.matrix.svd() s >> out_princlong, inter, short = [g.CombineXYZ(*u[i *4 : (i *4) +3]) for i inrange(3)]long>> out_long short >> out_short g.AxesToRotation(long, short) >> out_rotation inter * u.determinant().sign() >> out_inter
From a TreeBuilder
Trees built with nodebpy have a to_python() method directly. The generator recognises lifted operators, so math written with Python syntax comes back out the same way:
from nodebpy import TreeBuilderfrom nodebpy import geometry as gwith TreeBuilder("Wave Deform") as tree: geo = tree.inputs.geometry("Geometry") amp = tree.inputs.float("Amplitude", 0.5, min_value=0.0) out = tree.outputs.geometry("Geometry") height = g.Math.sine(g.Position().o.position.x) * amp geo >> g.SetPosition(offset=g.CombineXYZ(z=height)) >> outcode = tree.to_python()
Note what happened to the math: the Math node set to SINE becomes g.Math.sine(...), the multiply node becomes the * operator, and the Separate XYZ node dissolves into .x attribute access. Interface sockets are declared with their non-default settings (min_value=0.0) preserved.
Tuning the Output
Chain length
Linear runs of nodes are emitted as >> pipelines when they have at least min_chain_length items (default 3, counting interface endpoints). Raising the threshold produces flat assignments instead:
max_inline_width (default 88) limits how long an expression may grow before it is bound to a variable instead of inlining into its consumer. Deeply nested graphs split into named steps rather than collapsing into one huge statement; pass None to disable the budget.
Node positions
By default the generated code lets nodebpy lay the tree out automatically when it is built. Pass snapshot_positions=True to instead capture each node’s authored location and restore it, so the rebuilt tree matches the original layout. The tree is built with arrange=None (auto-layout off) and a tree.node_positions = {...} mapping is appended:
with TreeBuilder("Wave Deform", arrange=None) as tree: geo = tree.inputs.geometry("Geometry") out = tree.outputs.geometry("Geometry") geo >> g.SetPosition(offset=g.CombineXYZ(z=g.Position().o.position.x)) >> outcode = tree.to_python(snapshot_positions=True)
Positions are applied by name, so a node a rebuild does not recreate (a reroute) or renames is skipped rather than erroring. Positions inside nested groups are preserved too.
Reroutes
Reroute nodes are pure wire-routing aids, so by default each reroute chain is collapsed into a direct link. Pass keep_reroutes=True to preserve them as g.Reroute(...) pass-throughs — useful together with snapshot_positions to reproduce the original wire routing exactly.
Formatting
When the optional ruff package is installed (pip install nodebpy[format]), the generated source is run through ruff format for tidier output — for example wrapping long lines the generator left on one line. This is on by default; pass format=False to get the raw generator output, and it is a no-op when ruff is not installed.
Zones
Simulation, repeat and for-each zones are reconstructed using the zone item API rather than as raw input/output node pairs:
with TreeBuilder("Stack") as tree: out = tree.outputs.geometry("Geometry") zone = g.RepeatZone(5) geo = zone.item("Geometry", g.Cube()) (geo.current >> g.TransformGeometry(translation=(0, 0, 1.1))) >> geo.next geo.result >> outcode = tree.to_python()
from nodebpy import geometry as g, TreeBuilderwith TreeBuilder("Stack") as tree: geometry = tree.outputs.geometry("Geometry") repeat_zone = g.RepeatZone(5) geometry_1 = repeat_zone.item("Geometry", g.Cube().o.mesh) ( g.TransformGeometry(geometry=geometry_1.current, translation=(0.0, 0.0, 1.1))>> geometry_1.next ) geometry_1.result >> geometry
Frames
Frames are reconstructed as with g.Frame("..."): blocks, including frames nested inside other frames (and container frames that hold only sub-frames):
from nodebpy import geometry as g, TreeBuilderwith TreeBuilder("Framed") as tree: geometry = tree.inputs.geometry("Geometry") out = tree.outputs.geometry("Out")with g.Frame("Deform"):with g.Frame("Warp"): set_position = geometry >> g.SetPosition(offset=(0.0, 0.0, 1.0)) set_position >> g.SetShadeSmooth() >> out
Archiving as Classes
By default the top-level tree is emitted as a with TreeBuilder(...) as tree: block. Pass top_level="class" to instead emit every node group — including the one being exported — as a CustomGeometryGroup subclass. Each class builds its tree through ClassName.create_group(), making it a clean way to archive a set of node groups as plain, reusable Python:
with TreeBuilder("Wave Deform") as tree: geo = tree.inputs.geometry("Geometry") out = tree.outputs.geometry("Geometry") geo >> g.SetPosition(offset=g.CombineXYZ(z=g.Position().o.position.x)) >> outcode = tree.to_python(top_level="class")
If a node has no nodebpy class and no registered emitter, to_python() raises CodegenError by default. Pass strict=False to emit a placeholder and keep going:
Alternatively, register a custom emitter for the node type. The decorated function receives the bpy node and an emit context, and returns an expression (or None to fall back to the default emission):
from nodebpy.export.codegen import register_emitter, Call@register_emitter("GeometryNodeSetShadeSmooth")def _emit_shade_smooth(node, ctx):if node.domain =="FACE":return Call("g.SetShadeSmooth.face")returnNone
Round-Tripping
In the default with form the generated source assigns the builder to a variable named tree, so it can be executed directly to rebuild the tree:
(With top_level="class" there is no tree variable — call the generated class’s create_group() instead.)
Because to_python() output is itself valid nodebpy code, converting a tree, running the result, and converting again produces identical source — handy for snapshotting node trees in tests or committing them to version control. The generator is validated against Blender’s full bundled geometry, shader and compositor essentials asset libraries, so it handles real-world trees, not just ones built with nodebpy.