---
name: 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 }
---

# Blender Geometry Nodes Basics

## Purpose

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-material-shaders covers shader nodes for PBR materials: what a surface looks like, not what geometry exists. A tree in this skill outputs geometry. If a node changes color, roughness, or normals for shading, it belongs to the shader skill.
- headless-blender covers running Blender headless for rendering and batch work, not modeling. This skill uses headless runs only as a counting and checking tool for trees built here.
- blender-rigging-basics: "Rig a mesh in Blender: build an armature with clean bone layout and naming, bind the mesh, paint and clean weights, add constraints and IK, add control bones, and test deformation. Use when preparing a character or prop mesh so it can be posed and animated." Rigging starts after a mesh exists. This skill is one way a mesh comes to exist.
- blender-animation-basics: "Animate an already-rigged mesh in Blender: set actions and keyframes, shape timing in the Dope Sheet, refine curves and easing in the Graph Editor, build motion cycles, layer clips in the NLA, and check the result with preview renders. Use when a finished rig needs motion, from a single posed action to a small set of layered clips." Animation also starts after a mesh exists, and after it is rigged.
- This skill does not cover materials, UV unwrapping, rigging, animation, lighting, or rendering for delivery. The evaluated counts in Workflow step 10 are a geometry check, not a finished asset review.
- Tool claims in this skill were checked against Blender 5.1.1 on 2026-10-16 by running Blender headless (`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.

## Workflow

1. Start with a host object and a Geometry Nodes modifier.
   - A Geometry Nodes tree lives on a modifier, and a modifier lives on an object. The host object needs geometry of its own, even when the tree replaces most of it: a plane, a cube, or any simple mesh works. In Python, `bpy.ops.mesh.primitive_plane_add` and `bpy.ops.mesh.primitive_cube_add` create host objects headless, and the audit fixture uses both.
   - Add the modifier in the Properties editor with Add Modifier, then Geometry Nodes, then New, or in Python with `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.
   - Name the modifier for its job (`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.
   - Keep one tree per job. A second modifier with a second group is easier to debug, mute, and reorder than one large tree doing scatter, cleanup, and variation at once. Modifier order is stack order: the tree sees the result of every modifier above it.
   - Save a working copy before restructuring a tree that already produces something usable. Node edits apply live, and there is no separate draft state to fall back to.

2. Create the node group and its interface first.
   - A geometry node group is a data block of type `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`).
   - Define the interface before wiring the tree. The interface is the list of sockets the group takes in and sends out, managed through `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`.
   - The Group Input node carries the host object's incoming geometry plus every input socket on the interface. The Group Output node is the only exit: geometry that never reaches it does not exist in the result, however correct the middle of the tree looks.
   - Wire a straight pass-through first (Group Input geometry to Group Output geometry) and confirm the host object still shows. A tree that passes geometry unchanged is a known-good base. Every later step adds one branch at a time and re-checks, so a fault is always in the last branch added.

3. Expose group inputs as the parameters on the modifier.
   - Any interface input socket becomes a field on the modifier panel, with its default taken from the socket. Add one socket per value a user of the tree should change without opening the editor: counts, sizes, spacing, seed. Values buried as defaults inside nodes are settings nobody will find later.
   - Add sockets in Python with `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.
   - Name inputs for what they control, in plain words with units where it matters (`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.
   - Set sensible defaults and test at the extremes as well as the default. A count of 0, a negative size, and a count ten times the default are the three values that expose bad assumptions in a tree. The audit prints the modifier input values actually in force, which is the record to check.
   - Scripted reads and writes of modifier inputs use the socket identifier, not the display name, in 5.1.1. Verified: the fixture's `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.

4. Build the core flow from input to output.
   - Read a tree left to right: geometry enters at the Group Input, each node changes it or creates new geometry, and the last node links to the Group Output. A node with no path to the output is decoration.
   - Combine streams with Join Geometry (`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.
   - Decide early whether the tree adds to the incoming geometry, replaces it, or ignores it. All three are legitimate: scatter usually adds, an array setup usually replaces, and a pure generator ignores the input entirely. Write the choice in the setup summary, because it changes what the evaluated counts should be.
   - Keep node layout honest: inputs on the left, output on the right, one branch per row. A tree that cannot be read at a glance cannot be debugged at speed, and most tree faults are wiring faults.

5. Generate source geometry inside the tree.
   - Primitive nodes create geometry from numbers, with no host mesh needed: Grid (`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.
   - Grid takes `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).
   - Cube takes a `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.
   - Line (`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.
   - Primitive nodes output raw geometry in the tree's local space. The host object's transform still applies on top, so a scaled host scales the whole result. Apply or account for the host transform before judging sizes.

6. Scatter with points and Instance on Points.
   - The standard scatter chain has three links: a source mesh, a node that turns part of it into points, and Instance on Points (`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`.
   - For a regular pattern, turn source vertices into points with Mesh to Points (`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.
   - For coverage over a surface, use Distribute Points on Faces (`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.
   - Instances are references, not copies. An instanced ico sphere costs its 12 vertices once in memory however many points it lands on, and editing the instance source edits every copy at once. That economy is the reason to instance first and realize only when a later step needs real geometry (step 8).
   - Vary instances where they sit, not by making many sources: Rotation and Scale are per-point inputs on Instance on Points, and Scale Instances, Rotate Instances, and Translate Instances (`GeometryNodeScaleInstances`, `GeometryNodeRotateInstances`, `GeometryNodeTranslateInstances`) all exist in 5.1.1 for adjusting instances after placement.

7. Repeat geometry in an array-style row.
   - Geometry Nodes has no single array node in 5.1.1. The array pattern is a row of points plus instancing: Mesh Line in OFFSET mode makes the row, Mesh to Points converts it, and Instance on Points places the repeated item. It gives the same result as a classic array modifier, with count and offset as group inputs.
   - Fixture values, all verified by counting: Line `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.
   - Offset is per-step spacing, not total length. The row spans Count minus one times the offset: the fixture row spans 4 times 1.5, which is 6 units from first cube center to last. Off-by-one errors in total length come from forgetting that last step.
   - For repeating an existing mesh along its own elements rather than placing a new item, Duplicate Elements (`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.
   - Curved or irregular repetition is the same pattern with different points: any geometry that can be turned into points can carry the instances. Keep the point source and the instance source as separate branches so either can change without rewiring the other.

8. Decide whether to realize instances.
   - Realize Instances (`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.
   - Realize at the end of a branch, not the start. Realizing early multiplies the cost of every node after it and throws away the per-instance controls in step 6. The fixture realizes both trees only because its output contract is exact vertex and polygon counts, which are simplest to state on realized geometry.
   - Know the count change before realizing. Realized vertices equal instance source vertices times point count, plus anything joined in. Fixture scatter: 12 points times the ico sphere's 12 vertices is 144, plus the joined plane's 4, for 148 evaluated vertices and 241 polygons (12 times 20 triangles, plus the plane's 1 quad). If the counted result disagrees with that arithmetic, the tree is doing something other than what its layout claims.
   - Some downstream steps need realized geometry and some work better on instances. Deleting part of one copy, applying a modifier that only handles meshes, and exporting to a format with no instance support all need realized geometry. Leaving instances unrealized is the right default when none of those apply, and the summary should say which choice was made and why.

9. Modify the incoming geometry inside the tree.
   - A tree can also change the host mesh instead of only adding to it. Set Position (`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.
   - Transform Geometry (`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.
   - Delete Geometry (`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.
   - Merge by Distance (`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.
   - Modification nodes act on the stream they are wired into, in order. A delete placed before an instance branch and the same delete placed after it are different operations with different counts. When a result looks wrong, check node order on the branch before changing any values.

10. Check the result by counting the evaluated geometry.
    - Do not judge a tree by its node layout. Judge it by the evaluated result: what the modifier stack actually produces. In Python, get the object through the depsgraph with `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.
    - Predict the counts before counting (steps 5 to 8 give the arithmetic), then compare. A match confirms the wiring. A mismatch localizes the fault: too few instances means the point count is wrong, correct instances with wrong totals means the instance source is wrong, and base geometry missing from the total means the join in step 4 is missing or the tree replaces when it should add.
    - In the editor, a Viewer node (`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.
    - Check at more than one parameter setting. A tree that counts correctly at its defaults can still fail at Count 0 or at a large count. The fixture retune checks in step 11 are the minimum version of this.
    - An empty result is a finding, not a crash: zero evaluated vertices with a complete-looking tree almost always means a selection excluded everything, a branch never reached the output, or the modifier's group was never assigned (step 1).

11. Tune through the modifier inputs and re-check.
    - Once the tree is correct at its defaults, tune it from the modifier panel, not by reopening the editor for every change. That is what the exposed inputs in step 3 are for, and it is how the setup will be used after delivery.
    - Verified retune behavior in 5.1.1: on the fixture ArrayStrip, setting the modifier Count from 5 to 3 changed the evaluated result from 40 vertices, 30 polygons, and 60 edges to 24 vertices, 18 polygons, and 36 edges, exactly three cubes. On ScatterBase, setting Count X from 4 to 2 (with an update tag so the depsgraph refreshed) changed the result from 148 vertices and 241 polygons to 76 vertices and 121 polygons, exactly six ico sphere instances joined with the plane.
    - Change one input at a time and recount. Two inputs changed together with a surprising result leave no way to tell which one surprised.
    - Restore the intended delivery values after testing extremes, and confirm them in a final audit run. A setup delivered with its test values still dialed in is the procedural version of shipping a debug build.

12. Clean up and run the audit before delivery.
    - Remove dead branches and test nodes, name any node whose job is not obvious from its type, and confirm the Group Output receives every stream the recipe promises. Leave the pass-through check from step 2 impossible to fail silently: if the output link is missing, the setup summary must say the result is empty on purpose.
    - Decide the delivered state of the modifier stack. Leaving the NODES modifier live keeps the setup procedural and tunable, which is the usual delivery. Applying the modifier (`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.
    - Run the audit script from the Operating Rules section on the final file and put its full output in the setup summary. The audit proves the mechanical state: which objects carry trees, what the interfaces expose, what the modifier values are, and what the evaluated counts came to. It does not judge whether the scattered or repeated result looks right; that judgment stays with a visual check of the scene.
    - Fix audit findings in this order: a modifier with no group or a tree with no path to its output (the result is wrong or empty), evaluated counts that disagree with the step 8 arithmetic, modifier inputs left at test values, and only then naming and layout cosmetics.

## Output Contract

Deliver the Blender file with the procedural setup in place, plus a setup summary with these sections, in this order:

1. Scene summary: Blender version used, host object names, what each setup generates or modifies, and whether each tree adds to, replaces, or ignores the incoming geometry.
2. Audit output: the full output of gn_audit.py on the final file. On the worked fixture in the Operating Rules section the headline numbers are: geometry_nodes_objects 2. ArrayStrip: base mesh 8 vertices and 6 polygons, ArrayTree with 7 nodes and 6 links, modifier input Count 5, evaluated 40 vertices, 30 polygons, 60 edges. ScatterBase: base mesh 4 vertices and 1 polygon, ScatterTree with 8 nodes and 9 links, modifier inputs Count X 4 and Count Y 3, evaluated 148 vertices, 241 polygons, 364 edges.
3. Tree list: one row per node group with its name, the modifier and object it sits on, its interface inputs with their types and defaults, and one sentence stating what the tree does.
4. Parameter sheet: one row per exposed input with its current value, the values it was tested at, and the evaluated counts at the delivery value. Include any scripted retune checks, such as the fixture's Array Count 3 result of 24 vertices and Scatter Count X 2 result of 76 vertices.
5. Count check: the predicted vertex and polygon counts from the step 8 arithmetic next to the audited evaluated counts, with any difference explained rather than rounded away.
6. Instance decision: whether instances were realized in each tree, where in the branch, and the reason, plus the downstream steps that decision affects.
7. Limits: geometry the setup does not produce, parameter ranges known to break or explode the count, nodes left as placeholders, and anything deferred, such as a density-based surface scatter whose exact count varies with the host mesh.

## Operating Rules

1. Every tree ends at its Group Output. Geometry with no wired path to the output does not exist in the result. Before delivery, trace each branch to the output once, by eye or with the Viewer node.
2. Parameters live on the interface. Any value a user should change is a group input with a name, a default, and a place on the modifier panel. Values hidden inside nodes are findings in review, not conveniences.
3. Instance first, realize on purpose. Instancing is the default for repeated identical geometry. Realizing happens at a chosen point for a stated reason, and the count change is predicted before it is measured.
4. Counts are the contract. Predicted counts and audited evaluated counts appear in the summary for every delivered setup, at the delivery parameter values. A setup whose counts were never compared against a prediction is unchecked, whatever it looks like in the viewport.
5. Modifier inputs are read and written by identifier in scripts. In 5.1.1 the display name raises KeyError on a modifier; the socket identifier works. After a scripted change, tag the object for update before recounting.
6. Absolute rules in this skill are the assigned group on every NODES modifier, the output path check, interface exposure for tunable values, and the count comparison, because breaking any of them produces a setup that silently does nothing or produces uncounted geometry. Primitive choices, scatter patterns, grid sizes, and realize placement are guidance: choose for the job and record the choice in the summary.
7. No invented Blender calls. Every node type and property quoted in this skill was checked in Blender 5.1.1 as described in the Purpose section, including the names that turned out not to exist. When working in a different version, confirm a name in that build before scripting with it, and soften any instruction that depends on a name you could not confirm.
8. Use the Geometry Nodes audit script below for the mechanical checks. Save it as gn_audit.py, run it inside Blender as its header describes, and put its output in the setup summary. Report numbers from its output, not from memory of the file.

### Geometry Nodes audit (gn_audit.py)

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())
```
