Rendered headless by the example itself — click to zoom.
blender --background --python examples/gn-socket-rename/gn_socket_rename.py --
A jo-block (steel plinth + copper column) whose Geometry Nodes tree witnesses the 5.2 socket-identifier collapse on FunctionNodeCompare and FunctionNodeRandomValue. Follows geometry-nodes-python.
On 4.5.11 and 5.1.2, Compare INT exposes A_INT / B_INT and Random Value FLOAT exposes Min_001 / Max_001 / Value_001. On 5.2.1 those identifiers are gone — the live sockets reuse A / B and Min / Max / Value. Looking up the unique *enabled* socket of a given name wires on all three. Hard-coding the pre-5.2 identifiers raises on 5.2.
What it witnesses: identifier-agnostic wiring produces a 16-vert evaluated mesh (plinth + column) with POINT gauge_h == 1.80 on exactly the eight column verts. Compare INT 7 > 2 switches the column in (vert count). Random Value FLOAT with min=max=HEIGHT is stored as a named attribute (Random is a field; using it as a constant Size source evaluates to 0 — hazard found while authoring). zmax 1.94 is the construction axis, not the Random axis.
Scaffolding matches cross-version-property-delete (check() returns, argparse naive-API flag, FATAL wrapper). That example does not version-branch, so the per-version inventory assert is the gn-modifier-inputs shape.
What failure each check would catch:
- exit 4 — identifier inventory wrong for this Blender
- exit 5 — pre-5.2 identifier lookup failed (
--legacy-idson 5.2) - exit 6 — enabled-name lookup failed
- exit 7 — Compare did not switch the column in (8 verts)
- exit 8 — column not sitting on the plinth
- exit 9 —
gauge_hnot 1.80 on eight verts (Random unwired)
--legacy-ids is the falsifier: wire by Min_001 / A_INT. It exits 0 on 4.5.11 and 5.1.2 (those identifiers still exist) and 5 on 5.2.1. Unlike --same-axis, it is not red on every binary.
No SMOKE_SKIP. Do not pass deviation= to check_framing.
Run
blender --background --python gn_socket_rename.py --
blender --background --python gn_socket_rename.py -- --legacy-ids
blender --background --python gn_socket_rename.py -- --output gauge.png
The --output render path measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.
Exit codes
Per-script sequential checks. 9 is a valid check code; there is no rule against it. 10 is the shared framing helper.
| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 4 | Identifier inventory wrong for this Blender version | | 5 | Pre-5.2 identifier missing (--legacy-ids on 5.2) | | 6 | Enabled-name socket lookup failed | | 7 | Evaluated vert count off (Compare did not switch the column in) | | 8 | Evaluated zmax off closed form | | 9 | POINT gauge_h not 1.80 on eight column verts | | 10 | Gallery framing violation | | 12 | --output produced no file |
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output or --legacy-ids.
Source
"""GN Compare / Random Value socket identifier rename — a runnable example. Witnesses the 5.2 collapse of typed sockets onto reused identifiers. ``FunctionNodeRandomValue`` FLOAT used ``Min_001`` / ``Max_001`` / ``Value_001`` on 4.5 LTS and 5.1; those identifiers are gone on 5.2 (``Min`` / ``Max`` / ``Value``). ``FunctionNodeCompare`` INT used ``A_INT`` / ``B_INT``; 5.2 reuses ``A`` / ``B``. Identifier-agnostic lookup (the unique *enabled* socket of a given name) wires on all three. Hard-coding the pre-5.2 identifiers fails on 5.2. The tree is a jo-block: a plinth plus a column gated by Compare INT 7>2. Random Value FLOAT (min=max=HEIGHT, so the value is a closed form, not an RNG draw) is stored as POINT ``gauge_h`` on the column. Vert count is the Compare axis (16 vs 8). ``gauge_h == HEIGHT`` on exactly eight verts is the Random axis — count-only is green with Random unwired (the Store default is 0). Random Value is a field; wiring it as a constant Size source evaluates to 0, which is why the Store is the witness. blender --background --python gn_socket_rename.py -- blender --background --python gn_socket_rename.py -- --legacy-ids blender --background --python gn_socket_rename.py -- --output g.png """ import argparse import math import os import sys import bmesh import bpy sys.path.insert( 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir) ) sys.dont_write_bytecode = True import gallery_framing # noqa: E402 RENAMED_AT = (5, 2, 0) HEIGHT = 1.80 COLUMN_XY = 0.55 PLINTH_SIZE = (1.35, 1.35, 0.14) CMP_A = 7 CMP_B = 2 ZMAX_OK = PLINTH_SIZE[2] + HEIGHT # 1.94 VERTS_OK = 16 VERTS_NO_COLUMN = 8 ATTR_NAME = "gauge_h" ATTR_EPS = 1e-5 EXTENT_EPS = 1e-4 LEGACY_RV_MIN = "Min_001" LEGACY_RV_MAX = "Max_001" LEGACY_RV_OUT = "Value_001" LEGACY_CMP_A = "A_INT" LEGACY_CMP_B = "B_INT" def fail(msg, code): print(f"ERROR: {msg}", file=sys.stderr) return code def ident_sock(node, collection, identifier): for s in getattr(node, collection): if s.identifier == identifier: return s raise LookupError( f"{node.bl_idname} has no {collection} identifier {identifier!r}" ) def enabled_sock(node, collection, name): found = [ s for s in getattr(node, collection) if s.name == name and s.enabled ] if len(found) != 1: raise LookupError( f"{node.bl_idname} {collection} enabled name={name!r} " f"count={len(found)}" ) return found[0] def identifiers(node, collection): return {s.identifier for s in getattr(node, collection)} def eval_mesh(obj): dg = bpy.context.evaluated_depsgraph_get() ev = obj.evaluated_get(dg) me = ev.to_mesh() try: coords = [(v.co.x, v.co.y, v.co.z) for v in me.vertices] nfaces = len(me.polygons) attr = me.attributes.get(ATTR_NAME) if attr is None: attr_vals = [] else: attr_vals = [0.0] * len(me.vertices) attr.data.foreach_get("value", attr_vals) finally: ev.to_mesh_clear() return coords, nfaces, attr_vals def sock_pair(node, collection, name, legacy_id, legacy): if legacy: return ident_sock(node, collection, legacy_id) return enabled_sock(node, collection, name) def build_tree(material_plinth, material_column, legacy_ids): tree = bpy.data.node_groups.new("SocketRenameGauge", "GeometryNodeTree") tree.interface.new_socket( name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry", ) go = tree.nodes.new("NodeGroupOutput") plinth = tree.nodes.new("GeometryNodeMeshCube") plinth.inputs["Size"].default_value = PLINTH_SIZE plinth_xf = tree.nodes.new("GeometryNodeTransform") plinth_xf.inputs["Translation"].default_value = ( 0.0, 0.0, PLINTH_SIZE[2] / 2.0, ) tree.links.new(plinth.outputs["Mesh"], plinth_xf.inputs["Geometry"]) sm_p = tree.nodes.new("GeometryNodeSetMaterial") sm_p.inputs["Material"].default_value = material_plinth tree.links.new(plinth_xf.outputs["Geometry"], sm_p.inputs["Geometry"]) column = tree.nodes.new("GeometryNodeMeshCube") column.inputs["Size"].default_value = (COLUMN_XY, COLUMN_XY, HEIGHT) rv = tree.nodes.new("FunctionNodeRandomValue") rv.data_type = "FLOAT" cmp = tree.nodes.new("FunctionNodeCompare") cmp.data_type = "INT" cmp.operation = "GREATER_THAN" smin = sock_pair(rv, "inputs", "Min", LEGACY_RV_MIN, legacy_ids) smax = sock_pair(rv, "inputs", "Max", LEGACY_RV_MAX, legacy_ids) sout = sock_pair(rv, "outputs", "Value", LEGACY_RV_OUT, legacy_ids) smin.default_value = HEIGHT smax.default_value = HEIGHT sa = sock_pair(cmp, "inputs", "A", LEGACY_CMP_A, legacy_ids) sb = sock_pair(cmp, "inputs", "B", LEGACY_CMP_B, legacy_ids) sa.default_value = CMP_A sb.default_value = CMP_B cmp_out = enabled_sock(cmp, "outputs", "Result") col_xf = tree.nodes.new("GeometryNodeTransform") col_xf.inputs["Translation"].default_value = ( 0.0, 0.0, PLINTH_SIZE[2] + HEIGHT / 2.0, ) tree.links.new(column.outputs["Mesh"], col_xf.inputs["Geometry"]) store = tree.nodes.new("GeometryNodeStoreNamedAttribute") store.data_type = "FLOAT" store.domain = "POINT" store.inputs["Name"].default_value = ATTR_NAME tree.links.new(col_xf.outputs["Geometry"], store.inputs["Geometry"]) tree.links.new(sout, store.inputs["Value"]) sm_c = tree.nodes.new("GeometryNodeSetMaterial") sm_c.inputs["Material"].default_value = material_column tree.links.new(store.outputs["Geometry"], sm_c.inputs["Geometry"]) sw = tree.nodes.new("GeometryNodeSwitch") sw.input_type = "GEOMETRY" tree.links.new(cmp_out, enabled_sock(sw, "inputs", "Switch")) tree.links.new(sm_c.outputs["Geometry"], enabled_sock(sw, "inputs", "True")) join = tree.nodes.new("GeometryNodeJoinGeometry") tree.links.new(sm_p.outputs["Geometry"], join.inputs[0]) tree.links.new(enabled_sock(sw, "outputs", "Output"), join.inputs[0]) shade = tree.nodes.new("GeometryNodeSetShadeSmooth") shade.inputs["Shade Smooth"].default_value = False tree.links.new(join.outputs["Geometry"], shade.inputs["Geometry"]) tree.links.new(shade.outputs["Geometry"], go.inputs["Geometry"]) return tree, rv, cmp def check_inventory(rv, cmp): ver = bpy.app.version legacy = ver < RENAMED_AT rv_in = identifiers(rv, "inputs") rv_out = identifiers(rv, "outputs") cmp_in = identifiers(cmp, "inputs") print( f"blender={ver} legacy_ids_present_expected={legacy} " f"rv_in={sorted(rv_in)} rv_out={sorted(rv_out)} " f"cmp_in={sorted(cmp_in)}" ) old_rv = {LEGACY_RV_MIN, LEGACY_RV_MAX} <= rv_in and LEGACY_RV_OUT in rv_out old_cmp = {LEGACY_CMP_A, LEGACY_CMP_B} <= cmp_in print(f"old_random_ids={old_rv} old_compare_ids={old_cmp}") if legacy: if not old_rv: return fail("pre-5.2 Random Value identifiers missing", 4) if not old_cmp: return fail("pre-5.2 Compare INT identifiers missing", 4) else: if old_rv: return fail("pre-5.2 Random Value identifiers still present on 5.2+", 4) if old_cmp: return fail("pre-5.2 Compare INT identifiers still present on 5.2+", 4) return 0 def check(obj, rv, cmp, legacy_ids): code = check_inventory(rv, cmp) if code: return code bpy.context.view_layer.update() coords, nfaces, attr_vals = eval_mesh(obj) nverts = len(coords) zmax = max(c[2] for c in coords) if coords else float("-inf") n_hi = sum(1 for v in attr_vals if abs(v - HEIGHT) <= ATTR_EPS) n_lo = sum(1 for v in attr_vals if abs(v) <= ATTR_EPS) print( f"legacy_ids={legacy_ids} verts={nverts} faces={nfaces} " f"zmax={zmax:.6f} zmax_ok={ZMAX_OK:.6f} " f"gauge_h_hi={n_hi} gauge_h_lo={n_lo} attr_n={len(attr_vals)}" ) if nverts != VERTS_OK: return fail( f"evaluated verts {nverts} != {VERTS_OK} " f"(Compare did not switch the column in; no-column={VERTS_NO_COLUMN})", 7, ) if abs(zmax - ZMAX_OK) > EXTENT_EPS: return fail( f"evaluated zmax {zmax:.6f} != {ZMAX_OK:.6f}", 8, ) if n_hi != 8 or n_lo != 8: return fail( f"POINT {ATTR_NAME} hi={n_hi} lo={n_lo} (want 8/8 at {HEIGHT}); " "Random Value did not land on the column", 9, ) return 0 def principled(name, color, metallic, roughness, emission=None): mat = bpy.data.materials.new(name) mat.use_nodes = True bsdf = mat.node_tree.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness if emission is not None: sock = bsdf.inputs.get("Emission Color") or bsdf.inputs["Emission"] sock.default_value = emission bsdf.inputs["Emission Strength"].default_value = 0.12 return mat def build(legacy_ids): bpy.ops.wm.read_factory_settings(use_empty=True) steel = principled("PlinthSteel", (0.22, 0.23, 0.26, 1.0), 1.0, 0.32) copper = principled( "ColumnCopper", (0.93, 0.42, 0.08, 1.0), 1.0, 0.22, emission=(0.93, 0.42, 0.08, 1.0), ) try: tree, rv, cmp = build_tree(steel, copper, legacy_ids) except LookupError as exc: return None, None, None, fail(str(exc), 5 if legacy_ids else 6) me = bpy.data.meshes.new("Gauge") obj = bpy.data.objects.new("Gauge", me) bpy.context.collection.objects.link(obj) mod = obj.modifiers.new("GN", "NODES") mod.node_group = tree obj.rotation_euler = (0.0, 0.0, math.radians(38)) return obj, rv, cmp, 0 def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" def render_still(obj, path, engine): scene = bpy.context.scene floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) bm.to_mesh(floor_me) finally: bm.free() fmat = principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7) floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) wall.location = (0.0, 9.0, 0.0) wall.rotation_euler = (math.radians(90), 0.0, 0.0) scene.collection.objects.link(wall) world = bpy.data.worlds.new("World") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = ( 0.02, 0.021, 0.025, 1.0, ) scene.world = world aim = bpy.data.objects.new("Aim", None) aim.location = (0.0, 0.0, ZMAX_OK * 0.45) aim.hide_render = True scene.collection.objects.link(aim) def light(name, loc, energy, size, col): ld = bpy.data.lights.new(name, "AREA") ld.energy = energy ld.size = size ld.color = col ob = bpy.data.objects.new(name, ld) ob.location = loc scene.collection.objects.link(ob) lc = ob.constraints.new("TRACK_TO") lc.target = aim lc.track_axis = "TRACK_NEGATIVE_Z" lc.up_axis = "UP_Y" light("Key", (-3.4, -4.6, 5.4), 680.0, 4.2, (1.0, 0.96, 0.9)) light("Fill", (4.8, -3.0, 2.2), 140.0, 8.0, (0.75, 0.85, 1.0)) light("Rim", (0.2, 5.8, 3.4), 300.0, 3.2, (0.6, 0.78, 1.0)) light("Glint", (1.8, -4.8, 5.6), 900.0, 0.85, (1.0, 0.90, 0.70)) wedge = bpy.data.lights.new("Wedge", "AREA") wedge.energy = 480.0 wedge.size = 6.0 wedge.color = (1.0, 0.72, 0.42) wob = bpy.data.objects.new("Wedge", wedge) wob.location = (2.2, 5.2, 3.8) wob.rotation_euler = (math.radians(-68), 0.0, math.radians(190)) scene.collection.objects.link(wob) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (4.15, -5.85, 2.55) scene.collection.objects.link(cam) scene.camera = cam track = cam.constraints.new("TRACK_TO") track.target = aim track.track_axis = "TRACK_NEGATIVE_Z" track.up_axis = "UP_Y" scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() if engine == "cycles": scene.cycles.samples = 32 else: try: scene.eevee.taa_render_samples = 64 except AttributeError: pass scene.render.resolution_x = 1280 scene.render.resolution_y = 720 scene.render.image_settings.file_format = "PNG" scene.render.filepath = path scene.view_settings.view_transform = "Standard" fcode = gallery_framing.check_framing( scene, cam, hero=[obj], elements=[obj], stage=[floor, wall], ) if fcode: return fcode bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): print("ERROR: render produced no file", file=sys.stderr) return 12 return 0 def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() p.add_argument("--output", default=None) p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) p.add_argument( "--legacy-ids", action="store_true", help="falsification: wire Compare/Random Value by pre-5.2 identifiers", ) args = p.parse_args(argv) obj, rv, cmp, code = build(args.legacy_ids) if code: return code code = check(obj, rv, cmp, args.legacy_ids) if code: return code if args.output: rcode = render_still(obj, os.path.abspath(args.output), args.engine) if rcode: return rcode print(f"rendered still {args.output}") print("gn-socket-rename OK") return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as e: import traceback traceback.print_exc() print(f"FATAL: {e}", file=sys.stderr) sys.exit(1)