Blender add-on: geometry node graph actions, modifier input, evaluated snapshot

createNodeGroup, addNode, linkSockets, addModifier, setModifierInput (node_actions.py);
setObjectCustomProperty refuses a name that is a Geometry Nodes modifier input instead of
accepting with no effect; object snapshots carry the evaluated counts, bounds and locations.
Live proof on Blender 5.2.3: ring, boundary_step and seeded scatter rebuilt by actions only
match the native trees (66 checks).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-10 06:21:28 +00:00
commit 68275e3185
6 changed files with 1335 additions and 0 deletions

View file

@ -9,6 +9,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from . import node_actions as na
from . import scene_actions as sa
from .server_guard import BindRefused, SessionGuard, check_bind, guarded_server_class, is_loopback_host, purge_command_intents
@ -32,6 +33,8 @@ SYNC_VAR_SCHEMA = "brokkr.sync.var.v0"
SYNC_TIMELINE_BINDING_SCHEMA = "brokkr.sync.timeline_binding.v0"
SYNC_RECEIPT_SCHEMA = "brokkr.sync.receipt.v0"
EVALUATED_POSITION_CAP = 256
CAPABILITIES = (
"cultcache.mirror.publish",
"cultcache.intent.watch",
@ -50,6 +53,8 @@ CAPABILITIES = (
"light.write",
"world.write",
"render.still",
"nodegraph.geometry.write",
"geometry.evaluated.read",
"command.receipt.publish",
)
@ -73,6 +78,7 @@ class BrokkrBlenderTarget:
self._debug_mirror_root: str = ""
self.import_dir: str = ""
self.output_dir: str = ""
self.nodes = na.NodeActions(bpy_module)
def configure_action_dirs(self, import_dir: str, output_dir: str) -> None:
"""The directories importGltf and renderStill may touch; empty leaves that action disabled."""
@ -490,6 +496,9 @@ class BrokkrBlenderTarget:
return self._set_world(context, command)
if action == "renderStill":
return self._render_still(context, command)
if action in na.NodeActions.ACTIONS:
message, name = self.nodes.execute(action, command)
return self._receipt(command, "accepted", message, name)
return self._receipt(command, "failed", f"Unsupported Blender command action: {action}", "")
except sa.ActionRefused as error:
return self._receipt(command, "failed", str(error), "")
@ -566,6 +575,10 @@ class BrokkrBlenderTarget:
property_name = command.get("propertyName") or "brokkrValue"
if property_name.startswith("customProperties."):
property_name = property_name[len("customProperties."):]
modifier_name = na.drives_modifier_input(obj, property_name)
if modifier_name:
return self._receipt(command, "failed", f"{property_name!r} is an input of Geometry Nodes modifier {modifier_name!r}; "
"a custom property does not drive it, use setModifierInput.", obj.name)
obj[property_name] = command.get("value")
return self._receipt(command, "accepted", "Blender custom property updated.", obj.name)
@ -773,6 +786,7 @@ class BrokkrBlenderTarget:
"constraints": [constraint.name for constraint in obj.constraints],
"camera": self._camera_snapshot(data) if obj.type == "CAMERA" and data else None,
"mesh": self._mesh_snapshot(data) if obj.type == "MESH" and data else None,
"evaluated": self._evaluated_snapshot(obj),
"customProperties": self._custom_properties(obj.items()),
}
@ -794,6 +808,33 @@ class BrokkrBlenderTarget:
"shapeKeyCount": len(mesh.shape_keys.key_blocks) if mesh.shape_keys else 0,
}
def _evaluated_snapshot(self, obj: Any) -> dict[str, Any] | None:
"""What the modifier stack produced, for objects that have one: counts, bounds, and positions up to a cap.
A Geometry Nodes object's own mesh is empty, so the base mesh counts say nothing about it."""
if obj.type != "MESH" or not len(obj.modifiers):
return None
depsgraph = self.bpy.context.evaluated_depsgraph_get()
depsgraph.update()
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh()
try:
vertices = [tuple(round(c, 5) for c in v.co) for v in mesh.vertices]
counts = {"vertexCount": len(vertices), "edgeCount": len(mesh.edges), "polygonCount": len(mesh.polygons)}
finally:
evaluated.to_mesh_clear()
instances = [tuple(round(c, 5) for c in i.matrix_world.translation) for i in depsgraph.object_instances
if i.is_instance and i.parent is not None and i.parent.original == obj]
cloud = vertices + instances
return {
**counts,
"instanceCount": len(instances),
"boundsMin": [min(p[axis] for p in cloud) for axis in range(3)] if cloud else [],
"boundsMax": [max(p[axis] for p in cloud) for axis in range(3)] if cloud else [],
"vertexLocations": [list(p) for p in vertices[:EVALUATED_POSITION_CAP]],
"instanceLocations": [list(p) for p in instances[:EVALUATED_POSITION_CAP]],
"truncated": len(vertices) > EVALUATED_POSITION_CAP or len(instances) > EVALUATED_POSITION_CAP,
}
def _modifier_snapshot(self, modifier: Any) -> dict[str, Any]:
return {
"name": modifier.name,

View file

@ -0,0 +1,299 @@
"""Geometry node graph command actions: createNodeGroup, addNode, linkSockets, addModifier, setModifierInput.
Owner of how an intent becomes a node group, a node, a link or a modifier input on Blender 5.2.
Validation is plain Python and runs before bpy is touched; apply steps that can still fail after
Blender has been asked (an unknown node type, an enum that is not offered) undo what they made, so a
refused intent leaves the tree as it was. Docs: docs/blender-node-graphs.md.
"""
from __future__ import annotations
import math
import re
from typing import Any
from .scene_actions import ActionRefused
ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,63}$")
KEY_PATTERN = re.compile(r"^[A-Za-z0-9 ._-]{1,63}$")
# A geometry tree may hold geometry, function and shader math nodes. Nodes that read or write files,
# run code, write caches or nest groups are not part of this cut.
NODE_IDNAME = re.compile(r"^(GeometryNode|FunctionNode|ShaderNode)[A-Za-z0-9]+$")
NODE_DENIED = ("Import", "Export", "Script", "Bake", "Group", "Simulation", "Repeat", "Foreach", "Viewer", "TexImage", "TexEnvironment")
MAX_NODES = 500
MAX_INTERFACE = 64
MAX_MODIFIERS = 8
MAX_VALUE = 1.0e9
SOCKET_TYPES = {"Geometry": "NodeSocketGeometry", "Float": "NodeSocketFloat", "Int": "NodeSocketInt",
"Bool": "NodeSocketBool", "Vector": "NodeSocketVector"}
IN_OUT = {"INPUT", "OUTPUT"}
# Node properties that are not node settings, or that make a node reach outside the tree.
NODE_PROPERTY_DENIED = frozenset({"name", "label", "location", "width", "height", "parent", "node_tree", "select",
"mute", "hide", "show_options", "show_preview", "show_texture", "use_custom_color",
"color", "bl_idname", "bl_label", "bl_description", "bl_icon", "bl_static_type",
"bl_width_default", "bl_width_min", "bl_width_max", "bl_height_default",
"bl_height_min", "bl_height_max", "rna_type", "type", "internal_links"})
SETTABLE_PROPERTY_TYPES = ("ENUM", "BOOLEAN", "INT", "FLOAT")
def text(command: dict[str, Any], key: str, pattern: re.Pattern, label: str, *, required: bool = True) -> str:
value = command.get(key)
if value is None or value == "":
if required:
raise ActionRefused(f"{label} is required.")
return ""
if not isinstance(value, str) or not pattern.match(value):
raise ActionRefused(f"{label} must be 1..63 characters of letters, digits, '.', '_' or '-'.")
return value
def tree_key(command: dict[str, Any]) -> str:
kind = command.get("treeKind", "geometry")
if kind != "geometry":
raise ActionRefused(f"treeKind {kind!r} is not supported; only 'geometry'.")
return text(command, "treeKey", KEY_PATTERN, "treeKey")
def finite(value: Any, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or abs(value) > MAX_VALUE:
raise ActionRefused(f"{label} must be a finite number within +-{MAX_VALUE:g}.")
return float(value)
def typed_value(value: Any, kind: str, label: str) -> Any:
"""A value for a socket of `kind` (Blender's socket.type, or the interface type's tail), or ActionRefused."""
if kind in ("VALUE", "FLOAT", "Float"):
return finite(value, label)
if kind in ("INT", "Int"):
number = finite(value, label)
if number != int(number):
raise ActionRefused(f"{label} must be a whole number.")
return int(number)
if kind in ("BOOLEAN", "Bool"):
if not isinstance(value, bool):
raise ActionRefused(f"{label} must be true or false.")
return value
if kind in ("VECTOR", "Vector", "RGBA"):
size = 4 if kind == "RGBA" else 3
if not isinstance(value, (list, tuple)) or len(value) != size:
raise ActionRefused(f"{label} must be exactly {size} numbers.")
return tuple(finite(part, f"{label}[{i}]") for i, part in enumerate(value))
raise ActionRefused(f"{label}: socket type {kind} cannot be set by an intent.")
def check_idname(idname: Any) -> str:
if not isinstance(idname, str) or not NODE_IDNAME.match(idname):
raise ActionRefused("blIdname must be a GeometryNode*, FunctionNode* or ShaderNode* type.")
for denied in NODE_DENIED:
if denied in idname:
raise ActionRefused(f"node type {idname} is not allowed in this cut ({denied}).")
return idname
def check_interface(items: Any) -> list[dict[str, str]]:
if not isinstance(items, list) or not items or len(items) > MAX_INTERFACE:
raise ActionRefused(f"interface must be a list of 1..{MAX_INTERFACE} sockets.")
seen: set[tuple[str, str]] = set()
result = []
for i, item in enumerate(items):
if not isinstance(item, dict):
raise ActionRefused(f"interface[{i}] must be an object.")
in_out = str(item.get("inOut", "")).upper()
name = item.get("name")
socket_type = item.get("type")
if in_out not in IN_OUT or not isinstance(name, str) or not name or len(name) > 63 or socket_type not in SOCKET_TYPES:
raise ActionRefused(f"interface[{i}] needs inOut INPUT|OUTPUT, a name and a type of {sorted(SOCKET_TYPES)}.")
if (in_out, name) in seen:
raise ActionRefused(f"interface[{i}] repeats {in_out} socket {name!r}.")
seen.add((in_out, name))
entry = {"inOut": in_out, "name": name, "type": socket_type}
if "default" in item:
entry["default"] = typed_value(item["default"], socket_type, f"interface[{i}].default")
result.append(entry)
return result
class NodeActions:
"""Applies the node-graph actions to a bpy module. Stateless: every lookup goes to bpy.data."""
ACTIONS = ("createNodeGroup", "addNode", "linkSockets", "addModifier", "setModifierInput")
def __init__(self, bpy_module: Any):
self.bpy = bpy_module
def execute(self, action: str, command: dict[str, Any]) -> tuple[str, str]:
return getattr(self, "_" + action)(command)
# tree and node lookup
def _tree(self, key: str) -> Any:
tree = self.bpy.data.node_groups.get(key)
if tree is None:
raise ActionRefused(f"Node group {key!r} does not exist; createNodeGroup first.")
if tree.bl_idname != "GeometryNodeTree":
raise ActionRefused(f"Node group {key!r} is not a geometry node group.")
if getattr(tree, "library", None):
raise ActionRefused(f"Node group {key!r} is linked from a library and read-only.")
return tree
@staticmethod
def _node(tree: Any, node_id: str) -> Any:
for node in tree.nodes:
if node.get("brokkr_id") == node_id:
return node
raise ActionRefused(f"Node {node_id!r} does not exist in {tree.name!r}.")
@staticmethod
def _socket(sockets: Any, key: str, label: str) -> Any:
"""A socket of a node by identifier, else by display name when exactly one available socket has it."""
usable = [s for s in sockets if s.identifier and getattr(s, "enabled", True)]
for socket in usable:
if socket.identifier == key:
return socket
named = [s for s in usable if s.name == key]
if len(named) == 1:
return named[0]
raise ActionRefused(f"{label}: socket {key!r} " + ("is ambiguous by name; use its identifier." if named else "was not found."))
# actions
def _createNodeGroup(self, command: dict[str, Any]) -> tuple[str, str]:
key = tree_key(command)
interface = check_interface(command.get("interface"))
existing = self.bpy.data.node_groups.get(key)
if existing is not None:
raise ActionRefused(f"Node group {key!r} already exists.")
tree = self.bpy.data.node_groups.new(key, "GeometryNodeTree")
try:
made = []
for entry in interface:
socket = tree.interface.new_socket(entry["name"], in_out=entry["inOut"], socket_type=SOCKET_TYPES[entry["type"]])
if "default" in entry:
socket.default_value = entry["default"]
made.append(f"{entry['inOut'].lower()} {entry['name']}={socket.identifier}")
for node_id, idname in (("gin", "NodeGroupInput"), ("gout", "NodeGroupOutput")):
node = tree.nodes.new(idname)
node["brokkr_id"] = node_id
except Exception:
self.bpy.data.node_groups.remove(tree)
raise
return "Created node group; interface identifiers: " + ", ".join(made), tree.name
def _addNode(self, command: dict[str, Any]) -> tuple[str, str]:
tree = self._tree(tree_key(command))
node_id = text(command, "id", ID_PATTERN, "id")
idname = check_idname(command.get("blIdname"))
location = command.get("location", [0, 0])
if not isinstance(location, (list, tuple)) or len(location) != 2:
raise ActionRefused("location must be two numbers.")
location = [finite(part, "location") for part in location]
properties = command.get("properties") or {}
inputs = command.get("inputs") or {}
if not isinstance(properties, dict) or not isinstance(inputs, dict):
raise ActionRefused("properties and inputs must be objects.")
if any(n.get("brokkr_id") == node_id for n in tree.nodes):
raise ActionRefused(f"Node id {node_id!r} already exists in {tree.name!r}.")
if len(tree.nodes) >= MAX_NODES:
raise ActionRefused(f"Node group {tree.name!r} already has {MAX_NODES} nodes.")
try:
node = tree.nodes.new(idname)
except RuntimeError as error:
raise ActionRefused(f"Blender does not offer node type {idname}: {error}") from None
try:
node["brokkr_id"] = node_id
node.location = location
if command.get("label"):
node.label = str(command["label"])[:63]
for name, value in properties.items(): # node settings first: they decide which sockets exist
self._set_property(node, name, value)
for name, value in inputs.items():
socket = self._socket(node.inputs, name, f"input {name!r}")
if socket.is_linked or not hasattr(socket, "default_value"):
raise ActionRefused(f"input {name!r} has no settable default.")
socket.default_value = typed_value(value, socket.type, f"input {name!r}")
except Exception:
tree.nodes.remove(node)
raise
return "Node added.", node_id
def _set_property(self, node: Any, name: str, value: Any) -> None:
prop = node.bl_rna.properties.get(name) if isinstance(name, str) else None
if prop is None or prop.is_readonly or name in NODE_PROPERTY_DENIED or prop.type not in SETTABLE_PROPERTY_TYPES:
raise ActionRefused(f"{node.bl_idname} has no settable property {name!r}.")
if prop.type == "ENUM":
if not isinstance(value, str):
raise ActionRefused(f"property {name!r} needs an enum identifier.")
else:
value = typed_value(value, {"BOOLEAN": "BOOLEAN", "INT": "INT", "FLOAT": "FLOAT"}[prop.type], f"property {name!r}")
try:
setattr(node, name, value)
except (TypeError, ValueError) as error:
raise ActionRefused(f"property {name!r} cannot be {value!r}: {error}") from None
def _linkSockets(self, command: dict[str, Any]) -> tuple[str, str]:
tree = self._tree(tree_key(command))
source = self._node(tree, text(command, "fromNode", ID_PATTERN, "fromNode"))
target = self._node(tree, text(command, "toNode", ID_PATTERN, "toNode"))
out = self._socket(source.outputs, str(command.get("fromSocket", "")), "fromSocket")
into = self._socket(target.inputs, str(command.get("toSocket", "")), "toSocket")
link = tree.links.new(out, into)
if not link.is_valid:
tree.links.remove(link)
raise ActionRefused(f"Link {out.name} -> {into.name} is not valid (type or field/single-value mismatch).")
return f"Linked {source.get('brokkr_id')}.{out.identifier} -> {target.get('brokkr_id')}.{into.identifier}.", tree.name
def _object(self, command: dict[str, Any]) -> Any:
name = command.get("objectName") or command.get("targetObjectName")
obj = self.bpy.data.objects.get(name) if name else None
if obj is None:
raise ActionRefused(f"Object {name!r} was not found.")
return obj
def _addModifier(self, command: dict[str, Any]) -> tuple[str, str]:
obj = self._object(command)
kind = command.get("modifierType", "NODES")
if kind != "NODES":
raise ActionRefused("modifierType must be NODES; other modifiers are not part of this cut.")
tree = self._tree(tree_key(command))
name = text(command, "modifierName", KEY_PATTERN, "modifierName", required=False) or "Brokkr GeoNodes"
modifier = obj.modifiers.get(name)
if modifier is not None and modifier.type != "NODES":
raise ActionRefused(f"{obj.name!r} already has a non-Geometry-Nodes modifier named {name!r}.")
if modifier is None:
if len(obj.modifiers) >= MAX_MODIFIERS:
raise ActionRefused(f"{obj.name!r} already has {MAX_MODIFIERS} modifiers.")
modifier = obj.modifiers.new(name, "NODES")
modifier.node_group = tree
obj.update_tag()
return "Geometry Nodes modifier set.", obj.name
def _setModifierInput(self, command: dict[str, Any]) -> tuple[str, str]:
obj = self._object(command)
modifier = obj.modifiers.get(str(command.get("modifierName") or "Brokkr GeoNodes"))
if modifier is None or modifier.type != "NODES" or modifier.node_group is None:
raise ActionRefused(f"{obj.name!r} has no Geometry Nodes modifier with a node group of that name.")
item = modifier_input(modifier.node_group, str(command.get("socket", "")))
value = typed_value(command.get("value"), item.socket_type.replace("NodeSocket", ""), f"input {item.name!r}")
# modifier.properties.inputs[id] is a raw IDPropertyGroup on 5.2.3; attribute access reaches the struct.
getattr(modifier.properties.inputs, item.identifier).value = value
obj.update_tag() # without it a headless re-evaluation returned the previous result
return f"Modifier input {item.name} set.", obj.name
def modifier_input(tree: Any, key: str) -> Any:
"""An INPUT socket of a group's interface, by identifier or by its unique name."""
items = [i for i in tree.interface.items_tree if i.item_type == "SOCKET" and i.in_out == "INPUT"]
for item in items:
if item.identifier == key:
return item
named = [i for i in items if i.name == key]
if len(named) == 1:
return named[0]
raise ActionRefused(f"Interface input {key!r} " + ("is ambiguous by name; use its identifier." if named else "was not found."))
def drives_modifier_input(obj: Any, property_name: str) -> str:
"""The modifier whose group has an interface input called property_name (identifier or name), else ''."""
for modifier in obj.modifiers:
if modifier.type == "NODES" and modifier.node_group is not None:
for item in modifier.node_group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT" and property_name in (item.identifier, item.name):
return modifier.name
return ""

View file

@ -0,0 +1,28 @@
# Geometry node graph actions
Command intents (`brokkr.blender.command_intent.v0`) that build and drive a Geometry Nodes graph.
Owner: `brokkr_bridge/node_actions.py`; the target only dispatches. Every field is validated before
`bpy` is touched; a refused intent is a `failed` receipt naming the rule and leaves the tree as it was.
Design and the parts not built yet: `docs/blender-node-graphs.md` (on `libby/blender-node-graphs-design`).
| action | fields | rule |
|---|---|---|
| `createNodeGroup` | `treeKey`, `interface` `[{inOut, name, type, default?}]` | New geometry node group with `gin` and `gout` nodes. Types: Geometry, Float, Int, Bool, Vector. Refuses an existing group. The receipt lists the interface identifiers (`Socket_N`). |
| `addNode` | `treeKey`, `id`, `blIdname`, `location?`, `label?`, `properties?`, `inputs?` | `id` is Brokkr's key (stored as the node's `brokkr_id`). Type must be a `GeometryNode*`, `FunctionNode*` or `ShaderNode*` name that is not an importer, exporter, script, bake, group, zone, viewer or image-texture node. Properties are enum/bool/int/float node settings (set first, they decide which sockets exist); inputs are unlinked defaults (float, int, bool, vector, colour). At most 500 nodes per group. |
| `linkSockets` | `treeKey`, `fromNode`, `fromSocket`, `toNode`, `toSocket` | Sockets by identifier, or by display name when exactly one available socket has it (Group Input/Output sockets are addressed by interface name). A link Blender calls invalid (field into a single value, type mismatch) is removed and refused. |
| `addModifier` | `objectName`, `treeKey`, `modifierName?` (default `Brokkr GeoNodes`) | `NODES` only. Reuses a modifier of that name; at most 8 per object. |
| `setModifierInput` | `objectName`, `modifierName?`, `socket` (identifier or unique interface name), `value` | Float, Int, Bool, Vector inputs. Written as `getattr(modifier.properties.inputs, id).value = v` (indexing `inputs[id]` yields a raw ID property group without `.value` on 5.2.3), then `object.update_tag()`. |
`setObjectCustomProperty` refuses a property named like an input of a Geometry Nodes modifier on that
object (it never drove the modifier); other custom properties are unchanged.
The host snapshot's object entries carry `evaluated` for mesh objects with modifiers: vertex, edge and
polygon counts, `instanceCount`, bounds, and up to 256 vertex and instance locations (`truncated` when cut).
It is the evaluated result; `mesh` stays the base mesh.
Not built: zones, item sockets, multi-input ordering, removal and unlink actions, library groups,
group-in-group nodes, batches/rollback across intents, an expectedRevision check. Residual risk: values
that make evaluation expensive (a huge `Vertices` count) are bounded only to +-1e9; the snapshot evaluates
every object with modifiers each time it runs.
Live proof: `tests/live/gn_actions.py` (Blender 5.2.3, headless). Unit tests: `tests/test_node_actions.py`.

View file

@ -0,0 +1,164 @@
"""Live proof of the node-graph actions: rebuild three geometry-node setups through Brokkr actions only.
BROKKR_ADDON=<path to surfaces/blender/brokkr_bridge> \
blender -b --factory-startup --python gn_actions.py -- result.json
Each native tree is built with bpy as the reference and evaluated with the depsgraph. The Brokkr side
uses BrokkrBlenderTarget.execute_command for every build step and capture_snapshot().objects[].evaluated
for every read: no bpy call touches the Brokkr-built scene except the harness's own reference trees.
Exit status is non-zero if any check fails.
"""
import bpy, sys, os, json, types, math
from pathlib import Path
ADDON = Path(os.environ["BROKKR_ADDON"]).resolve()
pkg = types.ModuleType("brokkr_bridge"); pkg.__path__ = [str(ADDON)]
sys.modules["brokkr_bridge"] = pkg
from brokkr_bridge import blender_target as bt
out = sys.argv[sys.argv.index("--") + 1] if "--" in sys.argv else "gn_actions_result.json"
R = {"blender": bpy.app.version_string, "checks": []}
def check(name, ok, detail=None):
R["checks"].append({"name": name, "ok": bool(ok), "detail": detail}); print("CHECK", "ok " if ok else "FAIL", name, detail if not ok else "", flush=True)
def fresh():
bpy.ops.wm.read_factory_settings(use_empty=True)
return bt.BrokkrBlenderTarget(bpy)
N = [0]
def act(t, **cmd):
N[0] += 1; cmd.setdefault("commandId", "c%d" % N[0])
r = t.execute_command(bpy.context, cmd)
if r["status"] != "accepted": print("REFUSED", cmd["action"], r["message"], flush=True)
return r
def ok(t, **cmd):
r = act(t, **cmd); check("%s %s" % (cmd["action"], cmd.get("id") or cmd.get("treeKey") or cmd.get("objectName") or ""), r["status"] == "accepted", r["message"]); return r
def evaluated(t, name):
snap = t.capture_snapshot(bpy.context)
return [o for o in snap["objects"] if o["name"] == name][0]["evaluated"]
def native_tree(build):
g = bpy.data.node_groups.new("native", "GeometryNodeTree")
g.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
gout = g.nodes.new("NodeGroupOutput"); build(g, gout)
me = bpy.data.meshes.new("nm"); ob = bpy.data.objects.new("native_host", me); bpy.context.scene.collection.objects.link(ob)
ob.modifiers.new("GN", "NODES").node_group = g
dg = bpy.context.evaluated_depsgraph_get(); dg.update()
ev = ob.evaluated_get(dg); m = ev.to_mesh()
verts = sorted(tuple(round(c, 5) for c in v.co) for v in m.vertices); ev.to_mesh_clear()
inst = sorted(tuple(round(c, 5) for c in i.matrix_world.translation) for i in dg.object_instances if i.is_instance)
bpy.data.objects.remove(ob); bpy.data.node_groups.remove(g); bpy.data.meshes.remove(me)
return verts, inst
def srt(rows): return sorted(tuple(r) for r in rows)
def host(t, name, tree, mod="GN"):
ok(t, action="createObject", objectType="MESH", name=name)
ok(t, action="addModifier", objectName=name, modifierName=mod, treeKey=tree)
def graph(t, tree, interface, nodes, links):
ok(t, action="createNodeGroup", treeKey=tree, interface=interface)
for n in nodes: ok(t, action="addNode", treeKey=tree, **n)
for a, b, c, d in links: ok(t, action="linkSockets", treeKey=tree, fromNode=a, fromSocket=b, toNode=c, toSocket=d)
GEO_OUT = [{"inOut": "OUTPUT", "name": "Geometry", "type": "Geometry"}]
# ---- 1. ring of instances
def ring(g, gout):
c = g.nodes.new("GeometryNodeMeshCircle"); c.inputs["Vertices"].default_value = 8; c.inputs["Radius"].default_value = 2.0
cu = g.nodes.new("GeometryNodeMeshCube"); cu.inputs["Size"].default_value = (0.3, 0.3, 0.3)
i = g.nodes.new("GeometryNodeInstanceOnPoints")
g.links.new(c.outputs["Mesh"], i.inputs["Points"]); g.links.new(cu.outputs["Mesh"], i.inputs["Instance"]); g.links.new(i.outputs["Instances"], gout.inputs["Geometry"])
nv, ni = native_tree(ring)
t = fresh(); N[0] = 0
graph(t, "Ring", GEO_OUT,
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Vertices": 8, "Radius": 2.0}),
dict(id="cube", blIdname="GeometryNodeMeshCube", inputs={"Size": [0.3, 0.3, 0.3]}),
dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
[("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
host(t, "RingHost", "Ring")
ev = evaluated(t, "RingHost")
check("ring instances equal native", srt(ev["instanceLocations"]) == ni and ev["instanceCount"] == 8, {"brokkr": ev["instanceLocations"], "native": ni})
# parameter change by setModifierInput on an exposed input: rebuild with an exposed Count
t = fresh()
graph(t, "RingN", GEO_OUT + [{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}],
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Radius": 2.0}),
dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
[("gin", "Count", "circle", "Vertices"), ("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
host(t, "RingHost", "RingN")
before = evaluated(t, "RingHost")["instanceCount"]
ok(t, action="setModifierInput", objectName="RingHost", modifierName="GN", socket="Count", value=12)
after = evaluated(t, "RingHost")["instanceCount"]
check("setModifierInput changes evaluated instances", (before, after) == (8, 12), (before, after))
r = act(t, action="setObjectCustomProperty", targetObjectName="RingHost", propertyName="Count", value=3)
check("custom property on a modifier input is refused", r["status"] == "failed" and "setModifierInput" in r["message"], r["message"])
check("refused custom property changed nothing", evaluated(t, "RingHost")["instanceCount"] == 12)
r = act(t, action="setObjectCustomProperty", targetObjectName="RingHost", propertyName="tag", value=3)
check("other custom properties still accepted", r["status"] == "accepted", r["message"])
# ---- 2. boundary step
B = 0.5
def boundary(g, gout):
ln = g.nodes.new("GeometryNodeMeshLine"); ln.inputs["Count"].default_value = 3; ln.inputs["Offset"].default_value = (2, 0, 0)
pos = g.nodes.new("GeometryNodeInputPosition")
d = g.nodes.new("ShaderNodeVectorMath"); d.operation = "LENGTH"
c = g.nodes.new("FunctionNodeCompare"); c.data_type = "FLOAT"; c.operation = "GREATER_THAN"; c.inputs["B"].default_value = B
nm = g.nodes.new("ShaderNodeVectorMath"); nm.operation = "NORMALIZE"
sc = g.nodes.new("ShaderNodeVectorMath"); sc.operation = "SCALE"; sc.inputs["Scale"].default_value = B
sp = g.nodes.new("GeometryNodeSetPosition")
g.links.new(pos.outputs[0], d.inputs[0]); g.links.new(d.outputs["Value"], c.inputs["A"]); g.links.new(pos.outputs[0], nm.inputs[0]); g.links.new(nm.outputs[0], sc.inputs[0])
g.links.new(ln.outputs["Mesh"], sp.inputs["Geometry"]); g.links.new(c.outputs["Result"], sp.inputs["Selection"]); g.links.new(sc.outputs[0], sp.inputs["Position"]); g.links.new(sp.outputs["Geometry"], gout.inputs["Geometry"])
nv, ni = native_tree(boundary)
t = fresh()
graph(t, "Boundary", GEO_OUT,
[dict(id="line", blIdname="GeometryNodeMeshLine", inputs={"Count": 3, "Offset": [2, 0, 0]}),
dict(id="pos", blIdname="GeometryNodeInputPosition"),
dict(id="len", blIdname="ShaderNodeVectorMath", properties={"operation": "LENGTH"}),
dict(id="gt", blIdname="FunctionNodeCompare", properties={"data_type": "FLOAT", "operation": "GREATER_THAN"}, inputs={"B": B}),
dict(id="nrm", blIdname="ShaderNodeVectorMath", properties={"operation": "NORMALIZE"}),
dict(id="edge", blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": B}),
dict(id="set", blIdname="GeometryNodeSetPosition")],
[("pos", "Position", "len", "Vector"), ("len", "Value", "gt", "A"), ("pos", "Position", "nrm", "Vector"), ("nrm", "Vector", "edge", "Vector"),
("line", "Mesh", "set", "Geometry"), ("gt", "Result", "set", "Selection"), ("edge", "Vector", "set", "Position"), ("set", "Geometry", "gout", "Geometry")])
host(t, "BoundaryHost", "Boundary")
ev = evaluated(t, "BoundaryHost")
check("boundary_step vertices equal native", srt(ev["vertexLocations"]) == nv, {"brokkr": ev["vertexLocations"], "native": nv})
# ---- 3. seeded scatter
def scatter(g, gout):
gr = g.nodes.new("GeometryNodeMeshGrid"); gr.inputs["Size X"].default_value = 3; gr.inputs["Size Y"].default_value = 3
di = g.nodes.new("GeometryNodeDistributePointsOnFaces"); di.distribute_method = "RANDOM"; di.inputs["Density"].default_value = 4; di.inputs["Seed"].default_value = 7
co = g.nodes.new("GeometryNodeMeshCone"); co.inputs["Radius Bottom"].default_value = 0.05; co.inputs["Depth"].default_value = 0.4
i = g.nodes.new("GeometryNodeInstanceOnPoints")
g.links.new(gr.outputs["Mesh"], di.inputs["Mesh"]); g.links.new(di.outputs["Points"], i.inputs["Points"]); g.links.new(co.outputs["Mesh"], i.inputs["Instance"]); g.links.new(i.outputs["Instances"], gout.inputs["Geometry"])
nv, ni = native_tree(scatter)
t = fresh()
graph(t, "Scatter", GEO_OUT + [{"inOut": "INPUT", "name": "Seed", "type": "Int", "default": 7}],
[dict(id="grid", blIdname="GeometryNodeMeshGrid", inputs={"Size X": 3, "Size Y": 3}),
dict(id="dist", blIdname="GeometryNodeDistributePointsOnFaces", properties={"distribute_method": "RANDOM"}, inputs={"Density": 4, "Seed": 7}),
dict(id="cone", blIdname="GeometryNodeMeshCone", inputs={"Radius Bottom": 0.05, "Depth": 0.4}),
dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
[("grid", "Mesh", "dist", "Mesh"), ("dist", "Points", "iop", "Points"), ("cone", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
host(t, "ScatterHost", "Scatter")
ev = evaluated(t, "ScatterHost")
check("scatter instances equal native", srt(ev["instanceLocations"]) == ni and ev["instanceCount"] == len(ni), {"brokkr": ev["instanceCount"], "native": len(ni)})
ok(t, action="setModifierInput", objectName="ScatterHost", modifierName="GN", socket="Seed", value=7) # exposed but unwired: no effect
check("unwired exposed input leaves result", srt(evaluated(t, "ScatterHost")["instanceLocations"]) == ni)
# ---- refusals on the real API
r = act(t, action="addNode", treeKey="Scatter", id="bad", blIdname="GeometryNodeMeshCube", inputs={"Nope": 1}); check("unknown input refused", r["status"] == "failed", r["message"])
check("refused addNode left no node", not any(n.get("brokkr_id") == "bad" for n in bpy.data.node_groups["Scatter"].nodes))
r = act(t, action="addNode", treeKey="Scatter", id="bad2", blIdname="GeometryNodeMeshCube", properties={"nope": 1}); check("unknown property refused", r["status"] == "failed", r["message"])
r = act(t, action="addNode", treeKey="Scatter", id="bad3", blIdname="GeometryNodeImportOBJ"); check("file-reading node refused", r["status"] == "failed", r["message"])
r = act(t, action="addNode", treeKey="Scatter", id="bad4", blIdname="GeometryNodeNoSuchThing"); check("unknown node type refused", r["status"] == "failed", r["message"])
r = act(t, action="linkSockets", treeKey="Scatter", fromNode="cone", fromSocket="Mesh", toNode="dist", toSocket="Density"); check("invalid link refused and removed", r["status"] == "failed" and not any(l.to_socket.name == "Density" for l in bpy.data.node_groups["Scatter"].links), r["message"])
r = act(t, action="setModifierInput", objectName="ScatterHost", modifierName="GN", socket="Seed", value="x"); check("wrong value type refused", r["status"] == "failed", r["message"])
r = act(t, action="addNode", treeKey="Scatter", id="grid", blIdname="GeometryNodeMeshCube"); check("duplicate id refused", r["status"] == "failed", r["message"])
json.dump(R, open(out, "w"), indent=1, default=str)
bad = [c for c in R["checks"] if not c["ok"]]
print("DONE", len(R["checks"]), "checks,", len(bad), "failed")
sys.exit(1 if bad else 0)

View file

@ -0,0 +1,461 @@
{
"blender": "5.2.3 LTS",
"checks": [
{
"name": "createNodeGroup Ring",
"ok": true,
"detail": "Created node group; interface identifiers: output Geometry=Socket_0"
},
{
"name": "addNode circle",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode cube",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode iop",
"ok": true,
"detail": "Node added."
},
{
"name": "linkSockets Ring",
"ok": true,
"detail": "Linked circle.Mesh -> iop.Points."
},
{
"name": "linkSockets Ring",
"ok": true,
"detail": "Linked cube.Mesh -> iop.Instance."
},
{
"name": "linkSockets Ring",
"ok": true,
"detail": "Linked iop.Instances -> gout.Socket_0."
},
{
"name": "createObject ",
"ok": true,
"detail": "Blender object created."
},
{
"name": "addModifier Ring",
"ok": true,
"detail": "Geometry Nodes modifier set."
},
{
"name": "ring instances equal native",
"ok": true,
"detail": {
"brokkr": [
[
2.0,
0.0,
0.0
],
[
1.41421,
1.41421,
0.0
],
[
-0.0,
2.0,
0.0
],
[
-1.41421,
1.41421,
0.0
],
[
-2.0,
-0.0,
0.0
],
[
-1.41421,
-1.41421,
0.0
],
[
0.0,
-2.0,
0.0
],
[
1.41421,
-1.41421,
0.0
]
],
"native": [
[
-2.0,
-0.0,
0.0
],
[
-1.41421,
-1.41421,
0.0
],
[
-1.41421,
1.41421,
0.0
],
[
0.0,
-2.0,
0.0
],
[
-0.0,
2.0,
0.0
],
[
1.41421,
-1.41421,
0.0
],
[
1.41421,
1.41421,
0.0
],
[
2.0,
0.0,
0.0
]
]
}
},
{
"name": "createNodeGroup RingN",
"ok": true,
"detail": "Created node group; interface identifiers: output Geometry=Socket_0, input Count=Socket_1"
},
{
"name": "addNode circle",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode cube",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode iop",
"ok": true,
"detail": "Node added."
},
{
"name": "linkSockets RingN",
"ok": true,
"detail": "Linked gin.Socket_1 -> circle.Vertices."
},
{
"name": "linkSockets RingN",
"ok": true,
"detail": "Linked circle.Mesh -> iop.Points."
},
{
"name": "linkSockets RingN",
"ok": true,
"detail": "Linked cube.Mesh -> iop.Instance."
},
{
"name": "linkSockets RingN",
"ok": true,
"detail": "Linked iop.Instances -> gout.Socket_0."
},
{
"name": "createObject ",
"ok": true,
"detail": "Blender object created."
},
{
"name": "addModifier RingN",
"ok": true,
"detail": "Geometry Nodes modifier set."
},
{
"name": "setModifierInput RingHost",
"ok": true,
"detail": "Modifier input Count set."
},
{
"name": "setModifierInput changes evaluated instances",
"ok": true,
"detail": [
8,
12
]
},
{
"name": "custom property on a modifier input is refused",
"ok": true,
"detail": "'Count' is an input of Geometry Nodes modifier 'GN'; a custom property does not drive it, use setModifierInput."
},
{
"name": "refused custom property changed nothing",
"ok": true,
"detail": null
},
{
"name": "other custom properties still accepted",
"ok": true,
"detail": "Blender custom property updated."
},
{
"name": "createNodeGroup Boundary",
"ok": true,
"detail": "Created node group; interface identifiers: output Geometry=Socket_0"
},
{
"name": "addNode line",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode pos",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode len",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode gt",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode nrm",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode edge",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode set",
"ok": true,
"detail": "Node added."
},
{
"name": "linkSockets Boundary",
"ok": true,
"detail": "Linked pos.Position -> len.Vector."
},
{
"name": "linkSockets Boundary",
"ok": true,
"detail": "Linked len.Value -> gt.A."
},
{
"name": "linkSockets Boundary",
"ok": true,
"detail": "Linked pos.Position -> nrm.Vector."
},
{
"name": "linkSockets Boundary",
"ok": true,
"detail": "Linked nrm.Vector -> edge.Vector."
},
{
"name": "linkSockets Boundary",
"ok": true,
"detail": "Linked line.Mesh -> set.Geometry."
},
{
"name": "linkSockets Boundary",
"ok": true,
"detail": "Linked gt.Result -> set.Selection."
},
{
"name": "linkSockets Boundary",
"ok": true,
"detail": "Linked edge.Vector -> set.Position."
},
{
"name": "linkSockets Boundary",
"ok": true,
"detail": "Linked set.Geometry -> gout.Socket_0."
},
{
"name": "createObject ",
"ok": true,
"detail": "Blender object created."
},
{
"name": "addModifier Boundary",
"ok": true,
"detail": "Geometry Nodes modifier set."
},
{
"name": "boundary_step vertices equal native",
"ok": true,
"detail": {
"brokkr": [
[
0.0,
0.0,
0.0
],
[
0.5,
0.0,
0.0
],
[
0.5,
0.0,
0.0
]
],
"native": [
[
0.0,
0.0,
0.0
],
[
0.5,
0.0,
0.0
],
[
0.5,
0.0,
0.0
]
]
}
},
{
"name": "createNodeGroup Scatter",
"ok": true,
"detail": "Created node group; interface identifiers: output Geometry=Socket_0, input Seed=Socket_1"
},
{
"name": "addNode grid",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode dist",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode cone",
"ok": true,
"detail": "Node added."
},
{
"name": "addNode iop",
"ok": true,
"detail": "Node added."
},
{
"name": "linkSockets Scatter",
"ok": true,
"detail": "Linked grid.Mesh -> dist.Mesh."
},
{
"name": "linkSockets Scatter",
"ok": true,
"detail": "Linked dist.Points -> iop.Points."
},
{
"name": "linkSockets Scatter",
"ok": true,
"detail": "Linked cone.Mesh -> iop.Instance."
},
{
"name": "linkSockets Scatter",
"ok": true,
"detail": "Linked iop.Instances -> gout.Socket_0."
},
{
"name": "createObject ",
"ok": true,
"detail": "Blender object created."
},
{
"name": "addModifier Scatter",
"ok": true,
"detail": "Geometry Nodes modifier set."
},
{
"name": "scatter instances equal native",
"ok": true,
"detail": {
"brokkr": 38,
"native": 38
}
},
{
"name": "setModifierInput ScatterHost",
"ok": true,
"detail": "Modifier input Seed set."
},
{
"name": "unwired exposed input leaves result",
"ok": true,
"detail": null
},
{
"name": "unknown input refused",
"ok": true,
"detail": "input 'Nope': socket 'Nope' was not found."
},
{
"name": "refused addNode left no node",
"ok": true,
"detail": null
},
{
"name": "unknown property refused",
"ok": true,
"detail": "GeometryNodeMeshCube has no settable property 'nope'."
},
{
"name": "file-reading node refused",
"ok": true,
"detail": "node type GeometryNodeImportOBJ is not allowed in this cut (Import)."
},
{
"name": "unknown node type refused",
"ok": true,
"detail": "Blender does not offer node type GeometryNodeNoSuchThing: Error: Node type GeometryNodeNoSuchThing undefined\n"
},
{
"name": "invalid link refused and removed",
"ok": true,
"detail": "Link Mesh -> Density is not valid (type or field/single-value mismatch)."
},
{
"name": "wrong value type refused",
"ok": true,
"detail": "input 'Seed' must be a finite number within +-1e+09."
},
{
"name": "duplicate id refused",
"ok": true,
"detail": "Node id 'grid' already exists in 'Scatter'."
}
]
}

View file

@ -0,0 +1,342 @@
"""Node-graph action tests. No Blender: a fake bpy with just enough node-tree surface.
Run: python3 -m unittest discover -s surfaces/blender/tests -v
The real API is exercised by tests/live/gn_actions.py.
"""
from __future__ import annotations
import sys
import types
import unittest
from pathlib import Path
ADDON = Path(__file__).resolve().parents[1] / "brokkr_bridge"
package = types.ModuleType("brokkr_bridge")
package.__path__ = [str(ADDON)]
sys.modules.setdefault("brokkr_bridge", package)
from brokkr_bridge import blender_target as bt # noqa: E402
from brokkr_bridge import node_actions as na # noqa: E402
NS = types.SimpleNamespace
class Sock:
def __init__(self, identifier, name, kind="VALUE", default=0.0):
self.identifier, self.name, self.type, self.enabled = identifier, name, kind, True
self.default_value, self.is_linked = default, False
class Node(dict):
"""A node; ID properties are the dict part."""
PROPS = {"operation": NS(type="ENUM", is_readonly=False), "flag": NS(type="BOOLEAN", is_readonly=False),
"filepath": NS(type="STRING", is_readonly=False), "location": NS(type="FLOAT", is_readonly=False)}
def __init__(self, idname):
super().__init__()
self.bl_idname, self.location, self.label, self.operation, self.flag = idname, [0, 0], "", "ADD", False
self.bl_rna = NS(properties=self.PROPS)
self.inputs = [Sock("A", "A"), Sock("Size", "Size", "VECTOR", (1.0, 1.0, 1.0)), Sock("B", "Dup"), Sock("B_VEC", "Dup")]
self.outputs = [Sock("Out", "Out")]
def __setattr__(self, key, value):
if key == "operation" and value not in ("ADD", "MULTIPLY"):
raise TypeError("enum not found")
object.__setattr__(self, key, value)
class Nodes(list):
def new(self, idname):
if idname.endswith("Missing"):
raise RuntimeError("Node type undefined")
node = Node(idname)
self.append(node)
return node
def remove(self, node):
list.remove(self, node)
class Links(list):
def __init__(self, valid=True):
super().__init__()
self.valid = valid
def new(self, a, b):
link = NS(from_socket=a, to_socket=b, is_valid=self.valid)
self.append(link)
return link
def remove(self, link):
list.remove(self, link)
class Interface:
def __init__(self):
self.items_tree = []
def new_socket(self, name, in_out, socket_type):
item = NS(item_type="SOCKET", name=name, in_out=in_out, socket_type=socket_type, identifier=f"Socket_{len(self.items_tree)}", default_value=None)
self.items_tree.append(item)
return item
class Tree:
bl_idname = "GeometryNodeTree"
library = None
def __init__(self, name):
self.name, self.nodes, self.links, self.interface = name, Nodes(), Links(), Interface()
class Groups(dict):
def new(self, name, kind):
self[name] = Tree(name)
return self[name]
def remove(self, tree):
del self[tree.name]
class Inputs:
def __init__(self):
self.values = {}
def __getattr__(self, identifier):
return self.values.setdefault(identifier, NS(value=None))
class Modifiers(list):
def get(self, name):
return next((m for m in self if m.name == name), None)
def new(self, name, kind):
m = NS(name=name, type=kind, node_group=None, properties=NS(inputs=Inputs()))
self.append(m)
return m
class Obj:
def __init__(self, name):
self.name, self.type, self.data, self.modifiers, self.tagged = name, "MESH", None, Modifiers(), 0
def update_tag(self):
self.tagged += 1
def get(self, key, default=None):
return default
def __setitem__(self, key, value):
self.custom = (key, value)
class FakeBpy:
def __init__(self):
self.data = NS(node_groups=Groups(), objects={"Host": Obj("Host")})
class NodeActionTests(unittest.TestCase):
def setUp(self):
self.bpy = FakeBpy()
self.target = bt.BrokkrBlenderTarget(self.bpy)
self.n = 0
def run_action(self, **command):
self.n += 1
command.setdefault("commandId", f"c{self.n}")
return self.target.execute_command(None, command)
def accepted(self, **command):
receipt = self.run_action(**command)
self.assertEqual(receipt["status"], "accepted", receipt["message"])
return receipt
def refused(self, **command):
receipt = self.run_action(**command)
self.assertEqual(receipt["status"], "failed", receipt["message"])
return receipt
def group(self, interface=None):
self.accepted(action="createNodeGroup", treeKey="G", interface=interface or [{"inOut": "OUTPUT", "name": "Geometry", "type": "Geometry"}, {"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}])
return self.bpy.data.node_groups["G"]
# createNodeGroup
def test_group_gets_interface_and_stamped_group_nodes(self):
receipt = self.accepted(action="createNodeGroup", treeKey="G", interface=[{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}])
tree = self.bpy.data.node_groups["G"]
self.assertEqual([n.get("brokkr_id") for n in tree.nodes], ["gin", "gout"])
self.assertEqual(tree.interface.items_tree[0].default_value, 8)
self.assertIn("Count=Socket_0", receipt["message"])
def test_group_refusals_create_nothing(self):
for interface in (None, [], "x", [{"inOut": "SIDEWAYS", "name": "a", "type": "Int"}], [{"inOut": "INPUT", "name": "a", "type": "Mesh"}],
[{"inOut": "INPUT", "name": "a", "type": "Int"}, {"inOut": "INPUT", "name": "a", "type": "Float"}],
[{"inOut": "INPUT", "name": "a", "type": "Int", "default": 1.5}], [{"inOut": "INPUT", "name": "a", "type": "Vector", "default": [1, 2]}]):
self.refused(action="createNodeGroup", treeKey="G", interface=interface)
self.refused(action="createNodeGroup", treeKey="../x", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.refused(action="createNodeGroup", treeKind="material", treeKey="G", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertEqual(len(self.bpy.data.node_groups), 0)
def test_group_that_exists_is_not_overwritten(self):
self.group()
self.refused(action="createNodeGroup", treeKey="G", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertEqual(len(self.bpy.data.node_groups["G"].interface.items_tree), 2)
# addNode
def test_add_node_applies_properties_then_inputs_and_stamps_id(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="m", blIdname="ShaderNodeMath", location=[10, 20], properties={"operation": "MULTIPLY"}, inputs={"A": 3, "Size": [1, 2, 3]})
node = tree.nodes[-1]
self.assertEqual((node.operation, node.location, node.inputs[0].default_value, node.inputs[1].default_value), ("MULTIPLY", [10.0, 20.0], 3.0, (1.0, 2.0, 3.0)))
def test_add_node_refusals_leave_no_node(self):
tree = self.group()
before = len(tree.nodes)
cases = [dict(blIdname="GeometryNodeImportOBJ"), dict(blIdname="GeometryNodeBake"), dict(blIdname="NodeGroupInput"), dict(blIdname="bpy.ops"),
dict(blIdname="ShaderNodeMissing"), dict(blIdname="ShaderNodeMath", properties={"operation": "NOPE"}),
dict(blIdname="ShaderNodeMath", properties={"filepath": "/etc/passwd"}), dict(blIdname="ShaderNodeMath", properties={"location": 1.0}),
dict(blIdname="ShaderNodeMath", properties={"missing": 1}), dict(blIdname="ShaderNodeMath", inputs={"Dup": 1}),
dict(blIdname="ShaderNodeMath", inputs={"Nope": 1}), dict(blIdname="ShaderNodeMath", inputs={"A": float("nan")}),
dict(blIdname="ShaderNodeMath", inputs={"A": True}), dict(blIdname="ShaderNodeMath", inputs={"A": 1e12}),
dict(blIdname="ShaderNodeMath", inputs={"Size": [1, 2]}), dict(blIdname="ShaderNodeMath", location=[1])]
for case in cases:
self.refused(action="addNode", treeKey="G", id="x", **case)
self.assertEqual(len(tree.nodes), before)
def test_string_and_reserved_properties_are_refused_by_rule(self):
self.group()
for name in ("filepath", "location"):
receipt = self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", properties={name: "v"})
self.assertIn("no settable property", receipt["message"])
def test_add_node_refuses_bad_ids_duplicates_and_missing_tree(self):
self.group()
for bad in ("", "a b", "x" * 64, "../x", None):
self.refused(action="addNode", treeKey="G", id=bad, blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="gin", blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="Nope", id="a", blIdname="ShaderNodeMath")
def test_node_cap(self):
tree = self.group()
for i in range(na.MAX_NODES - 2):
tree.nodes.new("ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="over", blIdname="ShaderNodeMath")
def test_linked_library_group_is_read_only(self):
tree = self.group()
tree.library = NS(filepath="lib.blend")
self.refused(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
# linkSockets
def test_link_by_identifier_and_by_unique_name(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Size")
self.assertEqual(len(tree.links), 2)
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Dup") # name shared by two sockets
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="zzz", toSocket="A")
self.assertEqual(len(tree.links), 2)
def test_unavailable_socket_is_not_addressable_by_name(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
tree.nodes[-1].inputs[3].enabled = False
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="a", toSocket="Dup")
def test_invalid_link_is_refused_and_removed(self):
tree = self.group()
tree.links.valid = False
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="a", toSocket="A")
self.assertEqual(len(tree.links), 0)
# addModifier / setModifierInput
def test_modifier_assigns_group_and_reuses_by_name(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.assertEqual(len(host.modifiers), 1)
self.assertIs(host.modifiers[0].node_group, self.bpy.data.node_groups["G"])
self.refused(action="addModifier", objectName="Host", modifierType="SUBSURF", treeKey="G")
self.refused(action="addModifier", objectName="Nope", treeKey="G")
self.refused(action="addModifier", objectName="Host", treeKey="Nope")
def test_modifier_cap(self):
self.group()
for i in range(na.MAX_MODIFIERS):
self.accepted(action="addModifier", objectName="Host", modifierName=f"m{i}", treeKey="G")
self.refused(action="addModifier", objectName="Host", modifierName="extra", treeKey="G")
def test_set_modifier_input_writes_value_through_the_struct_and_tags(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
tags = host.tagged
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=12)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 12)
self.assertEqual(host.tagged, tags + 1)
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Socket_1", value=3)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 3)
def test_set_modifier_input_refusals_change_nothing(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for socket, value in (("Count", 1.5), ("Count", "x"), ("Count", True), ("Count", None), ("Count", 1e12), ("Nope", 1), ("Geometry", 1), ("", 1)):
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket=socket, value=value)
self.refused(action="setModifierInput", objectName="Host", modifierName="other", socket="Count", value=1)
self.assertEqual(host.modifiers[0].properties.inputs.values, {})
def test_set_modifier_input_by_name_needs_a_unique_name(self):
tree = self.group([{"inOut": "INPUT", "name": "Dup", "type": "Int"}])
tree.interface.new_socket("Dup", in_out="INPUT", socket_type="NodeSocketFloat") # a hand-made group can repeat a name
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket="Dup", value=1)
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Socket_1", value=1.5)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 1.5)
# setObjectCustomProperty on a Geometry Nodes host
def test_custom_property_naming_a_modifier_input_is_refused(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for name in ("Count", "Socket_1", "customProperties.Count"):
receipt = self.refused(action="setObjectCustomProperty", targetObjectName="Host", propertyName=name, value=3)
self.assertIn("setModifierInput", receipt["message"])
self.assertFalse(hasattr(host, "custom"))
def test_other_custom_properties_on_the_same_object_still_work(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.accepted(action="setObjectCustomProperty", targetObjectName="Host", propertyName="tag", value=3)
self.assertEqual(host.custom, ("tag", 3))
# snapshot
def test_evaluated_snapshot_reports_counts_bounds_and_cap(self):
host = self.bpy.data.objects["Host"]
host.modifiers.new("GN", "NODES")
verts = [NS(co=(float(i), 0.0, 0.0)) for i in range(3)]
mesh = NS(vertices=verts, edges=[1], polygons=[])
evaluated = NS(to_mesh=lambda: mesh, to_mesh_clear=lambda: None)
host.evaluated_get = lambda dg: evaluated
instances = [NS(is_instance=True, parent=NS(original=host), matrix_world=NS(translation=(0.0, 5.0 + i, 0.0))) for i in range(bt.EVALUATED_POSITION_CAP + 2)]
instances.append(NS(is_instance=True, parent=NS(original=Obj("other")), matrix_world=NS(translation=(9.0, 9.0, 9.0))))
instances.append(NS(is_instance=False, parent=None, matrix_world=NS(translation=(8.0, 8.0, 8.0))))
self.bpy.context = NS(evaluated_depsgraph_get=lambda: NS(update=lambda: None, object_instances=instances))
data = self.target._evaluated_snapshot(host)
self.assertEqual((data["vertexCount"], data["edgeCount"], data["instanceCount"]), (3, 1, bt.EVALUATED_POSITION_CAP + 2))
self.assertEqual((data["boundsMin"], data["boundsMax"]), ([0.0, 0.0, 0.0], [2.0, 5.0 + bt.EVALUATED_POSITION_CAP + 1, 0.0]))
self.assertEqual((len(data["instanceLocations"]), len(data["vertexLocations"]), data["truncated"]), (bt.EVALUATED_POSITION_CAP, 3, True))
def test_object_without_modifiers_has_no_evaluated_block(self):
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"]))
if __name__ == "__main__":
unittest.main()