Build procedural geometry in Blender with Geometry Nodes: put a node group on a modifier, expose group inputs as parameters, scatter and instance objects, repeat geometry in array-style rows, modify an existing mesh inside the tree, and check the evaluated result by counting it. Use when a mesh should be generated or varied by a node tree instead of being modeled by hand.
./install.sh blender-geometry-nodes-basicsname: blender_geometry_nodes_basics description: "Build procedural geometry in Blender with Geometry Nodes: put a node group on a modifier, expose group inputs as parameters, scatter and instance objects, repeat geometry in array-style rows, modify an existing mesh inside the tree, and check the evaluated result by counting it. Use when a mesh should be generated or varied by a node tree instead of being modeled by hand." metadata: { "includeInPrompt": true }
Generate and modify geometry with a node tree: attach a Geometry Nodes modifier to a host object, build the tree from a group input to a group output, expose the values worth changing as group inputs so they appear on the modifier, scatter copies across points, repeat geometry in rows, change the base mesh inside the tree, and prove the result by counting the evaluated geometry. This skill covers geometry generation and modification only, from a host mesh to a checked procedural result.
Scope:
blender -b --factory-startup -noaudio --python script.py). Every node type quoted below was created in that build and accepted, and the audit numbers were produced by a real run, not recalled. Two names that look plausible do not exist in 5.1.1: GeometryNodeGrid (the grid node is GeometryNodeMeshGrid) and GeometryNodeTransformGeometry (the transform node is GeometryNodeTransform). GeometryNodeInstanceOnFaces also does not exist in this build. Blender renames nodes between major versions, so re-check a quoted name in the Python console of a newer build before relying on it.bpy.ops.mesh.primitive_plane_add and bpy.ops.mesh.primitive_cube_add create host objects headless, and the audit fixture uses both.obj.modifiers.new(name, type="NODES") and mod.node_group = group. Both parts matter: a NODES modifier with no group assigned does nothing, and a node group with no modifier using it affects nothing.Scatter, Array) and the group for its recipe (ScatterTree, ArrayTree). When a file misbehaves, the audit prints object, modifier, and group names in that order, and real names make the fault findable.GeometryNodeTree: bpy.data.node_groups.new(name, "GeometryNodeTree") creates one in 5.1.1. In the editor, the New button on the modifier creates the same thing and drops in the two nodes every tree needs: a Group Input node (NodeGroupInput) and a Group Output node (NodeGroupOutput).group.interface.new_socket(name, in_out, socket_type) in Python, verified in 5.1.1 for NodeSocketGeometry and NodeSocketInt. Every procedural setup needs at least one geometry input and one geometry output, both NodeSocketGeometry.group.interface.new_socket, then set default_value on the returned socket. Verified fixture values: Count X and Count Y as NodeSocketInt with defaults 4 and 3 on the scatter tree, and Count with default 5 on the array tree.Count X, Spacing, Radius), and keep names stable once other files use the group. Renaming a socket is safe for links inside the tree, but scripts and documentation that refer to the old name break silently.Count socket has identifier Socket_1, mod["Socket_1"] returns 5, and mod["Count"] raises KeyError. The audit script in the Operating Rules section reads values through item.identifier for this reason. After a scripted change, tag the object for update (obj.update_tag() plus a view layer update) before re-counting, or the evaluated result can still show the old values.GeometryNodeJoinGeometry, confirmed in 5.1.1). Its single Geometry input accepts multiple links, which is how a tree keeps the base object and adds generated geometry on top. The fixture's scatter tree joins the incoming plane with the realized instances, which is why its evaluated result includes the plane's 4 vertices and 1 polygon.GeometryNodeMeshGrid), Cube (GeometryNodeMeshCube), Ico Sphere (GeometryNodeMeshIcoSphere), and UV Sphere (GeometryNodeMeshUVSphere) all exist in 5.1.1. Socket names quoted here were read from the nodes in that build.Size X, Size Y, Vertices X, and Vertices Y, and outputs Mesh. Vertices are the useful numbers for scatter work: a grid with Vertices X 4 and Vertices Y 3 has 12 vertices and 6 quad faces, which becomes 12 scatter points in step 6. Drive Vertices X and Vertices Y from group inputs so the density is a modifier parameter (step 3).Size vector and per-axis vertex counts. Ico Sphere takes Radius and Subdivisions: at Subdivisions 1 it has 12 vertices and 20 triangular faces, small enough to instance hundreds of times and still count exactly. Raise subdivisions only when the silhouette needs it, because instance cost multiplies by point count.GeometryNodeMeshLine) generates a row of vertices: its mode defaults to OFFSET in 5.1.1, and it takes Count, Start Location, and Offset. It is the base of the array pattern in step 7.GeometryNodeInstanceOnPoints) placing one copy of an instance geometry at each point. Its inputs are Points, Selection, Instance, Pick Instance, Instance Index, Rotation, and Scale, and it outputs Instances.GeometryNodeMeshToPoints), which takes a Mesh input and outputs Points. The fixture chain is Grid to Mesh to Points to Instance on Points, with an Ico Sphere as the instance: 12 grid vertices give 12 points and 12 instances.GeometryNodeDistributePointsOnFaces) instead. It takes a Mesh input and exposes Density, Density Factor, and Seed among its inputs. Density-driven scatter is the right tool for organic coverage, but its point count follows area and density rather than an exact vertex count, so check it by counting the result (step 10) instead of predicting it. Set the seed deliberately and record it: the same seed is what makes a reviewed scatter reproducible.GeometryNodeScaleInstances, GeometryNodeRotateInstances, GeometryNodeTranslateInstances) all exist in 5.1.1 for adjusting instances after placement.Count 5 driven by the group input, Offset (1.5, 0.0, 0.0), a Cube primitive at Size (0.8, 0.8, 0.8) as the instance. Five points give five cubes in a row along X, each cube contributing 8 vertices and 6 polygons.GeometryNodeDuplicateElements) exists in 5.1.1: it takes Geometry, Selection, and Amount, and its domain property defaults to POINT. Duplication makes real geometry per repeat, so counts and memory grow with Amount. Prefer the instancing pattern when the repeats are identical and nothing downstream needs them separate.GeometryNodeRealizeInstances) converts instances into regular geometry: one input, Geometry, one output of the same name. After realizing, every copy is independent geometry that later nodes can deform, delete from, or join with other meshes.GeometryNodeSetPosition) moves points by a Position value or an Offset, which covers bends and offsets driven by fields built from nodes such as Position (GeometryNodeInputPosition) and Index (GeometryNodeInputIndex), both confirmed in 5.1.1.GeometryNodeTransform) applies a uniform move to a whole geometry stream: its inputs in 5.1.1 are Geometry, Mode, Translation, Rotation, Scale, and Transform. Use it to place a generated branch before joining, rather than moving the host object and shifting everything else with it.GeometryNodeDeleteGeometry) removes the elements its Selection input marks, with domain and mode properties controlling what counts as an element. Separate Geometry (GeometryNodeSeparateGeometry) splits a stream by selection instead, so both parts can be treated differently and joined again later. Both exist in 5.1.1.GeometryNodeMergeByDistance) and Subdivision Surface (GeometryNodeSubdivisionSurface) also exist in 5.1.1 and cover two common cleanup and smoothing jobs inside a tree. Keep such nodes in the tree only when the change is part of the procedural recipe. A one-off fix belongs in the mesh itself, where the next person will see it.obj.evaluated_get(depsgraph), convert with to_mesh(), and count vertices, polygons, and edges. That is exactly what the audit script does, and its fixture numbers are the reference for this skill.GeometryNodeViewer) shows an intermediate stream without rewiring the output, which is the fastest way to find which branch diverges. Check the stream before and after the suspect node, not only the final result.bpy.ops.object.modifier_apply exists in 5.1.1) bakes the current result into the mesh and ends the procedural behavior for that object: do it only when a downstream step requires plain geometry, and keep an unapplied copy of the file.Deliver the Blender file with the procedural setup in place, plus a setup summary with these sections, in this order:
Run: blender -b --factory-startup -noaudio --python gn_audit.py -- --make-fixture reproduces the worked example. Run it on a real file with blender -b your_file.blend -noaudio --python gn_audit.py. On the default factory scene it prints blender_version 5.1.1 and geometry_nodes_objects: 0.
Expected output for the fixture run:
text
blender_version: 5.1.1
geometry_nodes_objects: 2
object: ArrayStrip
base_mesh: vertices=8 polygons=6
modifier: Array: type=NODES group=ArrayTree show_viewport=True
interface_inputs: Geometry (NodeSocketGeometry), Count (NodeSocketInt)
interface_outputs: Geometry (NodeSocketGeometry)
nodes: 7: GeometryNodeInstanceOnPoints, GeometryNodeMeshCube, GeometryNodeMeshLine, GeometryNodeMeshToPoints, GeometryNodeRealizeInstances, NodeGroupInput, NodeGroupOutput
links: 6
modifier_inputs: Count=5
evaluated: vertices=40 polygons=30 edges=60
object: ScatterBase
base_mesh: vertices=4 polygons=1
modifier: Scatter: type=NODES group=ScatterTree show_viewport=True
interface_inputs: Geometry (NodeSocketGeometry), Count X (NodeSocketInt), Count Y (NodeSocketInt)
interface_outputs: Geometry (NodeSocketGeometry)
nodes: 8: GeometryNodeInstanceOnPoints, GeometryNodeJoinGeometry, GeometryNodeMeshGrid, GeometryNodeMeshIcoSphere, GeometryNodeMeshToPoints, GeometryNodeRealizeInstances, NodeGroupInput, NodeGroupOutput
links: 9
modifier_inputs: Count X=4, Count Y=3
evaluated: vertices=148 polygons=241 edges=364
The fixture is built so each number can be checked by hand. ScatterBase is a plane (4 vertices, 1 polygon). Its grid runs at Count X 4 and Count Y 3, so 12 points carry 12 ico spheres at Subdivisions 1 (12 vertices, 20 polygons, 30 edges each), realized and joined with the plane: 4 plus 144 is 148 vertices, 1 plus 240 is 241 polygons, 4 plus 360 is 364 edges. ArrayStrip is a cube host whose own geometry the tree replaces: the line runs at Count 5 with Offset (1.5, 0.0, 0.0), so 5 cubes at 8 vertices, 6 polygons, and 12 edges each give 40 vertices, 30 polygons, and 60 edges.
gn_audit.py (run inside Blender 5.1.1, uses only bpy and sys). Save this code as gn_audit.py:
python
#!/usr/bin/env python3
"""Audit Geometry Nodes setups in the current Blender scene. Blender 5.1.1.
Run inside Blender, not with system python:
blender -b --factory-startup -noaudio --python gn_audit.py
blender -b your_file.blend -noaudio --python gn_audit.py
Reproduce the worked example in the skill (builds a fixture first):
blender -b --factory-startup -noaudio --python gn_audit.py -- --make-fixture
What it reports: every object that carries a Nodes modifier, the node
group name, modifier input values, node and link counts for the tree,
the group interface sockets, and the evaluated result of the modifier
stack counted as vertices, polygons, and edges through the depsgraph.
"""
import sys
import bpy
def fmt(v):
return f"{v:g}"
def add_socket(group, name, in_out, socket_type, default=None):
sock = group.interface.new_socket(name=name, in_out=in_out, socket_type=socket_type)
if default is not None:
sock.default_value = default
return sock
def build_scatter_tree():
group = bpy.data.node_groups.new("ScatterTree", "GeometryNodeTree")
add_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
add_socket(group, "Count X", "INPUT", "NodeSocketInt", 4)
add_socket(group, "Count Y", "INPUT", "NodeSocketInt", 3)
add_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
gin = nodes.new("NodeGroupInput")
gout = nodes.new("NodeGroupOutput")
grid = nodes.new("GeometryNodeMeshGrid")
grid.inputs["Size X"].default_value = 4.0
grid.inputs["Size Y"].default_value = 4.0
to_points = nodes.new("GeometryNodeMeshToPoints")
ico = nodes.new("GeometryNodeMeshIcoSphere")
ico.inputs["Radius"].default_value = 0.2
ico.inputs["Subdivisions"].default_value = 1
inst = nodes.new("GeometryNodeInstanceOnPoints")
realize = nodes.new("GeometryNodeRealizeInstances")
join = nodes.new("GeometryNodeJoinGeometry")
links.new(gin.outputs["Count X"], grid.inputs["Vertices X"])
links.new(gin.outputs["Count Y"], grid.inputs["Vertices Y"])
links.new(grid.outputs["Mesh"], to_points.inputs["Mesh"])
links.new(to_points.outputs["Points"], inst.inputs["Points"])
links.new(ico.outputs["Mesh"], inst.inputs["Instance"])
links.new(inst.outputs["Instances"], realize.inputs["Geometry"])
links.new(gin.outputs["Geometry"], join.inputs["Geometry"])
links.new(realize.outputs["Geometry"], join.inputs["Geometry"])
links.new(join.outputs["Geometry"], gout.inputs["Geometry"])
return group
def build_array_tree():
group = bpy.data.node_groups.new("ArrayTree", "GeometryNodeTree")
add_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
add_socket(group, "Count", "INPUT", "NodeSocketInt", 5)
add_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
gin = nodes.new("NodeGroupInput")
gout = nodes.new("NodeGroupOutput")
line = nodes.new("GeometryNodeMeshLine")
line.inputs["Offset"].default_value = (1.5, 0.0, 0.0)
to_points = nodes.new("GeometryNodeMeshToPoints")
cube = nodes.new("GeometryNodeMeshCube")
cube.inputs["Size"].default_value = (0.8, 0.8, 0.8)
inst = nodes.new("GeometryNodeInstanceOnPoints")
realize = nodes.new("GeometryNodeRealizeInstances")
links.new(gin.outputs["Count"], line.inputs["Count"])
links.new(line.outputs["Mesh"], to_points.inputs["Mesh"])
links.new(to_points.outputs["Points"], inst.inputs["Points"])
links.new(cube.outputs["Mesh"], inst.inputs["Instance"])
links.new(inst.outputs["Instances"], realize.inputs["Geometry"])
links.new(realize.outputs["Geometry"], gout.inputs["Geometry"])
return group
def make_fixture():
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
bpy.ops.mesh.primitive_plane_add(size=4.0)
scatter_obj = bpy.context.active_object
scatter_obj.name = "ScatterBase"
scatter_group = build_scatter_tree()
mod = scatter_obj.modifiers.new(name="Scatter", type="NODES")
mod.node_group = scatter_group
bpy.ops.mesh.primitive_cube_add(size=1.0)
array_obj = bpy.context.active_object
array_obj.name = "ArrayStrip"
array_group = build_array_tree()
mod2 = array_obj.modifiers.new(name="Array", type="NODES")
mod2.node_group = array_group
return [scatter_obj, array_obj]
def evaluated_counts(obj):
depsgraph = bpy.context.evaluated_depsgraph_get()
ev = obj.evaluated_get(depsgraph)
mesh = ev.to_mesh()
try:
return len(mesh.vertices), len(mesh.polygons), len(mesh.edges)
finally:
ev.to_mesh_clear()
def audit():
print(f"blender_version: {bpy.app.version_string}")
gn_objects = []
for obj in sorted(bpy.data.objects, key=lambda o: o.name):
mods = [m for m in obj.modifiers if m.type == "NODES"]
if mods:
gn_objects.append((obj, mods))
print(f"geometry_nodes_objects: {len(gn_objects)}")
for obj, mods in gn_objects:
base_verts = len(obj.data.vertices) if hasattr(obj.data, "vertices") else 0
base_polys = len(obj.data.polygons) if hasattr(obj.data, "polygons") else 0
print(f"object: {obj.name}")
print(f" base_mesh: vertices={base_verts} polygons={base_polys}")
for mod in mods:
group = mod.node_group
print(f" modifier: {mod.name}: type={mod.type} group={group.name if group else '-'} show_viewport={mod.show_viewport}")
if group:
ins = []
outs = []
for item in group.interface.items_tree:
if getattr(item, "item_type", None) != "SOCKET":
continue
entry = f"{item.name} ({item.socket_type})"
if item.in_out == "INPUT":
ins.append(entry)
else:
outs.append(entry)
print(f" interface_inputs: {', '.join(ins) if ins else '-'}")
print(f" interface_outputs: {', '.join(outs) if outs else '-'}")
print(f" nodes: {len(group.nodes)}: {', '.join(sorted(n.bl_idname for n in group.nodes))}")
print(f" links: {len(group.links)}")
# modifier exposed input values, read by socket identifier
vals = []
for item in group.interface.items_tree:
if getattr(item, "item_type", None) != "SOCKET" or item.in_out != "INPUT":
continue
if item.socket_type == "NodeSocketGeometry":
continue
# In 5.1.1 a modifier stores input values under the
# socket identifier (for example Socket_1), not the
# display name. Reading by name raises KeyError.
try:
v = mod[item.identifier]
except Exception:
v = item.default_value if hasattr(item, "default_value") else "-"
vals.append(f"{item.name}={fmt(v) if isinstance(v,(int,float)) else v}")
print(f" modifier_inputs: {', '.join(vals) if vals else '-'}")
verts, polys, edges = evaluated_counts(obj)
print(f" evaluated: vertices={verts} polygons={polys} edges={edges}")
def main():
args = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if "--make-fixture" in args:
make_fixture()
audit()
return 0
if __name__ == "__main__":
raise SystemExit(main())
publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e
No ratings yet — be the first to rate it.