Blender add-on: geometry node graph actions, modifier inputs, evaluated snapshot #5

Merged
libby merged 10 commits from libby/blender-gn-recreation into main 2026-11-10 17:54:18 +00:00
10 changed files with 2926 additions and 2 deletions

View file

@ -244,6 +244,13 @@ fn build_provider_advertisement() -> ProviderAdvertisement {
"material.assign",
"selection.read",
"selection.write",
"asset.import.gltf",
"camera.write",
"light.write",
"world.write",
"render.still",
"nodegraph.geometry.write",
"geometry.evaluated.read",
"sync.session.publish",
"sync.object.bind",
"sync.var.publish",
@ -1985,6 +1992,17 @@ mod tests {
assert!(blender.capabilities.contains(&"object.graph.read"));
assert!(blender.capabilities.contains(&"object.transform.write"));
assert!(blender.capabilities.contains(&"material.assign"));
for scene_and_graph in [
"asset.import.gltf",
"camera.write",
"light.write",
"world.write",
"render.still",
"nodegraph.geometry.write",
"geometry.evaluated.read",
] {
assert!(blender.capabilities.contains(&scene_and_graph), "{scene_and_graph}");
}
let route_sources: Vec<_> = provider
.realtime_routes

View file

@ -220,6 +220,9 @@ All Blender writes use `brokkr.blender.command_intent.v0` and receive
- `setLight`
- `setWorld`
- `renderStill`
- `createNodeGroup`, `addNode`, `removeNode`, `linkSockets`, `unlinkSockets`, `addModifier`,
`removeModifier`, `setModifierInput` (Geometry Nodes graphs; fields in
`surfaces/blender/docs/node-actions.md`)
Blender owns the mutation. Brokkr advertises the command surface; Verse clients
write typed command intents; Blender executes recognized intents and publishes

View file

@ -5,10 +5,12 @@ import math
import os
import sys
import uuid
from array import array
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 +34,10 @@ 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
EVALUATED_VERTEX_LIMIT = 1_000_000
EVALUATED_INSTANCE_SCAN_LIMIT = 100_000 # depsgraph items visited per capture; iterating one in Python costs ~5 us
CAPABILITIES = (
"cultcache.mirror.publish",
"cultcache.intent.watch",
@ -50,6 +56,8 @@ CAPABILITIES = (
"light.write",
"world.write",
"render.still",
"nodegraph.geometry.write",
"geometry.evaluated.read",
"command.receipt.publish",
)
@ -73,6 +81,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."""
@ -441,6 +450,7 @@ class BrokkrBlenderTarget:
def capture_snapshot(self, context: Any) -> dict[str, Any]:
bpy = self.bpy
scan = self._instance_scan() if any(o.type == "MESH" and len(o.modifiers) for o in bpy.data.objects) else None
selected = [item.name for item in context.selected_objects if item is not None]
active_object = context.active_object.name if context.active_object else ""
active_scene = context.scene.name if context.scene else ""
@ -463,7 +473,7 @@ class BrokkrBlenderTarget:
"imageCount": len(bpy.data.images),
"capabilities": list(CAPABILITIES),
"scenes": [self._scene_snapshot(scene) for scene in bpy.data.scenes],
"objects": [self._object_snapshot(obj) for obj in bpy.data.objects],
"objects": [self._object_snapshot(obj, scan) for obj in bpy.data.objects],
"materials": [self._material_snapshot(material) for material in bpy.data.materials],
"collections": [self._collection_snapshot(collection) for collection in bpy.data.collections],
}
@ -497,6 +507,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), "")
@ -573,6 +586,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)
@ -761,7 +778,7 @@ class BrokkrBlenderTarget:
"collectionName": scene.collection.name if scene.collection else "",
}
def _object_snapshot(self, obj: Any) -> dict[str, Any]:
def _object_snapshot(self, obj: Any, scan: Any = None) -> dict[str, Any]:
data = obj.data if hasattr(obj, "data") else None
return {
"name": obj.name,
@ -780,6 +797,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, scan),
"customProperties": self._custom_properties(obj.items()),
}
@ -801,6 +819,70 @@ class BrokkrBlenderTarget:
"shapeKeyCount": len(mesh.shape_keys.key_blocks) if mesh.shape_keys else 0,
}
def _instance_scan(self) -> Any:
"""One pass over the depsgraph's instances: per original parent object, the count, the first locations
and the running bounds (world space). Nothing is kept per instance beyond the cap."""
depsgraph = self.bpy.context.evaluated_depsgraph_get()
depsgraph.update()
found: dict[str, dict[str, Any]] = {}
visited = 0
complete = True
for item in depsgraph.object_instances:
visited += 1
if visited > EVALUATED_INSTANCE_SCAN_LIMIT:
complete = False
break
if not item.is_instance or item.parent is None:
continue
entry = found.setdefault(item.parent.original.name, {"count": 0, "first": [], "min": None, "max": None})
point = tuple(float(c) for c in item.matrix_world.translation)
entry["count"] += 1
if len(entry["first"]) < EVALUATED_POSITION_CAP:
entry["first"].append([round(c, 5) for c in point])
entry["min"] = list(point) if entry["min"] is None else [min(a, b) for a, b in zip(entry["min"], point)]
entry["max"] = list(point) if entry["max"] is None else [max(a, b) for a, b in zip(entry["max"], point)]
return depsgraph, found, complete
def _evaluated_snapshot(self, obj: Any, scan: Any) -> dict[str, Any] | None:
"""What the modifier stack produced, for mesh objects that have one. Vertex positions and their bounds
are in the object's local space; instance locations and their bounds are world space. A Geometry Nodes
object's own mesh is empty, so the base mesh counts say nothing about it. Above EVALUATED_VERTEX_LIMIT
vertices only the counts are read, and any non-finite coordinate drops the positions and sets nonFinite."""
if obj.type != "MESH" or not len(obj.modifiers) or scan is None:
return None
depsgraph, found, complete = scan
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh()
try:
count = len(mesh.vertices)
result = {"vertexCount": count, "edgeCount": len(mesh.edges), "polygonCount": len(mesh.polygons)}
coords = array("f", bytes(12 * count)) if 0 < count <= EVALUATED_VERTEX_LIMIT else None
if coords is not None:
mesh.vertices.foreach_get("co", coords)
finally:
evaluated.to_mesh_clear()
entry = found.get(obj.name)
reached = entry is not None
entry = entry or {"count": 0, "first": [], "min": [], "max": []}
finite = all(math.isfinite(c) for c in coords) if coords is not None else True
finite = finite and all(math.isfinite(c) for c in (entry["min"] or []) + (entry["max"] or []) + [c for p in entry["first"] for c in p])
points = [coords[i:i + 3] for i in range(0, 3 * count, 3)] if coords is not None else []
result.update({
# An object the stopped scan never reached has an unknown count, not zero.
"instanceCount": entry["count"] if complete or reached else None,
"instancesComplete": complete,
"positionsSkipped": count > EVALUATED_VERTEX_LIMIT,
"nonFinite": not finite,
"vertexBoundsMin": [min(p[a] for p in points) for a in range(3)] if points and finite else [],
"vertexBoundsMax": [max(p[a] for p in points) for a in range(3)] if points and finite else [],
"vertexLocations": [[round(c, 5) for c in p] for p in points[:EVALUATED_POSITION_CAP]] if finite else [],
"instanceBoundsMin": entry["min"] if finite and entry["min"] else [],
"instanceBoundsMax": entry["max"] if finite and entry["max"] else [],
"instanceLocations": entry["first"] if finite else [],
"truncated": count > EVALUATED_POSITION_CAP or entry["count"] > EVALUATED_POSITION_CAP,
})
return result
def _modifier_snapshot(self, modifier: Any) -> dict[str, Any]:
return {
"name": modifier.name,

View file

@ -0,0 +1,375 @@
"""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/node-actions.md. The design is PR #3 (branch libby/blender-node-graphs-design).
"""
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, nest groups or sample images 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",
"ImageTexture", "InputImage", "TexIES")
MAX_NODES = 500
MAX_INTERFACE = 64
MAX_MODIFIERS = 8
MAX_VALUE = 1.0e9
MAX_INT = 4096 # counts, levels and sizes multiply: a 4096 x 4096 grid is the ceiling
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.fullmatch(value):
raise ActionRefused(f"{label} must be 1..63 characters of letters, digits, space, '.', '_' 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) or abs(number) > MAX_INT:
raise ActionRefused(f"{label} must be a whole number within +-{MAX_INT}.")
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.fullmatch(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", "removeNode", "linkSockets", "unlinkSockets", "addModifier", "removeModifier", "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 _removeNode(self, command: dict[str, Any]) -> tuple[str, str]:
tree = self._tree(tree_key(command))
node_id = text(command, "id", ID_PATTERN, "id")
if node_id in ("gin", "gout"):
raise ActionRefused(f"{node_id!r} is the group's own input/output node and stays.")
node = next((n for n in tree.nodes if n.get("brokkr_id") == node_id), None)
if node is None:
return f"Node {node_id!r} was already absent.", node_id
tree.nodes.remove(node) # Blender drops the node's links with it
return "Node removed.", node_id
def _unlinkSockets(self, command: dict[str, Any]) -> tuple[str, str]:
tree = self._tree(tree_key(command))
target = self._node(tree, text(command, "toNode", ID_PATTERN, "toNode"))
into = self._socket(target.inputs, str(command.get("toSocket", "")), "toSocket")
source = out = None
if command.get("fromNode") or command.get("fromSocket"):
source = self._node(tree, text(command, "fromNode", ID_PATTERN, "fromNode"))
out = self._socket(source.outputs, str(command.get("fromSocket", "")), "fromSocket")
doomed = [l for l in tree.links if l.to_socket == into and (out is None or l.from_socket == out)]
for link in doomed:
tree.links.remove(link)
return f"Removed {len(doomed)} link(s).", tree.name
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")
if reaches(tree, target, source): # includes a self link
raise ActionRefused(f"Link {source.get('brokkr_id')} -> {target.get('brokkr_id')} would make a cycle.")
displaced = [(l.from_socket, l.is_muted) for l in tree.links if l.to_socket == into] if not getattr(into, "is_multi_input", False) else []
link = tree.links.new(out, into) # replaces whatever single link the input had
if not link.is_valid:
tree.links.remove(link)
for old, muted in displaced:
tree.links.new(old, into).is_muted = muted
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")
if modifier is None:
raise ActionRefused(f"{obj.name!r} is a {obj.type} object; it cannot take a Geometry Nodes modifier.")
modifier.node_group = tree
obj.update_tag()
return "Geometry Nodes modifier set.", obj.name
def _removeModifier(self, command: dict[str, Any]) -> tuple[str, str]:
obj = self._object(command)
# A lookup, not a creation: a modifier made by hand may have any name Blender allows.
name = command.get("modifierName") or "Brokkr GeoNodes"
if not isinstance(name, str) or len(name) > 255 or "\0" in name:
raise ActionRefused("modifierName must be a string of at most 255 characters.")
modifier = obj.modifiers.get(name)
if modifier is None:
return f"Modifier {name!r} was already absent.", obj.name
if modifier.type != "NODES":
raise ActionRefused(f"{name!r} is not a Geometry Nodes modifier; only those are removed here.")
obj.modifiers.remove(modifier)
obj.update_tag()
return "Modifier removed.", 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 node_key(node: Any) -> Any:
"""A stable identity for a node: bpy hands out a fresh wrapper per access, so id() of one is reused by the next."""
return node.as_pointer() if hasattr(node, "as_pointer") else id(node)
def reaches(tree: Any, start: Any, goal: Any) -> bool:
"""True if following links forward from `start` arrives at `goal` (so a link goal -> start closes a cycle).
The adjacency is built once per call, so a call costs O(links)."""
forward: dict[Any, list[Any]] = {}
for link in tree.links:
forward.setdefault(node_key(link.from_node), []).append(link.to_node)
target = node_key(goal)
seen: set[Any] = set()
stack = [start]
while stack:
node = stack.pop()
key = node_key(node)
if key == target:
return True
if key in seen:
continue
seen.add(key)
stack.extend(forward.get(key, ()))
return False
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 with identifier property_name (Socket_N), else ''.
An object custom property never drives a modifier input, so only the modifier's own identifiers are
refused; a property that merely shares an input's display name is an ordinary custom property."""
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 == item.identifier:
return modifier.name
return ""

View file

@ -0,0 +1,39 @@
# 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: the design document lives in PR #3 (branch `libby/blender-node-graphs-design`), not in this tree.
| 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. |
| `removeNode` | `treeKey`, `id` | Removes the node and its links. Absent id: accepted. `gin` and `gout` stay. |
| `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, and the single link it displaced is put back. A valid link into a single-link input replaces the old one (Blender's behaviour); a link that would close a cycle, or link a node to itself, is refused before Blender is asked. |
| `unlinkSockets` | `treeKey`, `toNode`, `toSocket`, `fromNode?`, `fromSocket?` | Removes the links into an input, or only the one from the given source. None to remove: accepted. |
| `addModifier` | `objectName`, `treeKey`, `modifierName?` (default `Brokkr GeoNodes`) | `NODES` only. Reuses a modifier of that name; at most 8 per object. |
| `removeModifier` | `objectName`, `modifierName?` | Geometry Nodes modifiers only (a Subdivision or other modifier is refused). Absent: accepted. |
| `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 whose name is the identifier (`Socket_N`) of an input of a Geometry
Nodes modifier on that object; a custom property never drives a modifier input. A name that only equals an
input's display name is an ordinary custom property.
The host snapshot's object entries carry `evaluated` for mesh objects with modifiers: vertex, edge and
polygon counts, `instanceCount`, and up to 256 vertex and instance locations (`truncated` when cut). Vertex
positions and `vertexBounds*` are in the object's local space; instance locations and `instanceBounds*` are
world space. Above 1,000,000 vertices only counts are read (`positionsSkipped`); a non-finite coordinate sets
`nonFinite` and drops the positions and bounds. The instances come from one depsgraph scan per capture.
`instancesComplete` is false when the depsgraph scan stopped at 100,000 items (`instanceCount` and the instance bounds are then lower bounds); the scan is what costs time (about 5 microseconds per instance in Python), so a 2000 x 2000 grid instanced on points captures in 1.4 s on the test machine where an unbounded scan took 22 s. `instanceCount` is `null` for an object the stopped scan never reached (unknown, not zero); the 100,000 budget is shared across all objects in depsgraph order, so a host after a heavy one can be starved. Otherwise `instanceCount` counts evaluated instances whose parent is the object, so a curve, point or volume result
also reads as one instance. `mesh` stays the base mesh.
Known edges: a node duplicated by hand copies its `brokkr_id`, so id lookups act on the first match; `gin`/`gout` are protected by id, and a group whose output node was deleted by hand cannot get a new one through `addNode`; removing a group's last modifier does not delete the group (it may stay in the file until it is saved and reloaded; `addModifier` can reuse it); `removeModifier` takes any modifier name Blender allows because it only looks the modifier up.
Not built: zones, item sockets, multi-input ordering, library groups, group-in-group nodes, batches/rollback across
intents, an expectedRevision check, per-node cost estimates. Int values are capped at +-4096 and floats at +-1e9,
which bounds a request but not the cost of a graph: Blender evaluates what it is given, and the snapshot evaluates
every mesh with modifiers on each capture (mesh grids measured: 16.7M vertices evaluate and read in 0.4 s).
`addModifier` refuses object types that take no modifiers.
Live proof: `tests/live/gn_actions.py` (Blender 5.2.3, headless). Unit tests: `tests/test_node_actions.py`.

View file

@ -0,0 +1,269 @@
"""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="Socket_1", value=3)
check("custom property naming a modifier input identifier 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="Count", value=3)
check("a custom property sharing only a display name is ordinary", 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"])
t2 = fresh()
graph(t2, "L", GEO_OUT, [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="m", blIdname="ShaderNodeMath"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
[("cube", "Mesh", "iop", "Points")])
before = [(l.from_node.get("brokkr_id"), l.to_socket.identifier) for l in bpy.data.node_groups["L"].links if l.to_node.get("brokkr_id") == "iop"]
r = act(t2, action="linkSockets", treeKey="L", fromNode="m", fromSocket="Value", toNode="iop", toSocket="Points")
after = [(l.from_node.get("brokkr_id"), l.to_socket.identifier) for l in bpy.data.node_groups["L"].links if l.to_node.get("brokkr_id") == "iop"]
check("refused link keeps the link it would have replaced", r["status"] == "failed" and before == after == [("cube", "Points")], (r["message"], before, after))
ok(t2, action="createObject", objectType="CAMERA", name="Cam")
r = act(t2, action="addModifier", objectName="Cam", treeKey="L"); check("modifier on a camera refused clearly", r["status"] == "failed" and "CAMERA" in r["message"], r["message"])
r = act(t2, action="addNode", treeKey="L", id="n\n", blIdname="ShaderNodeMath"); check("trailing newline in an id refused", r["status"] == "failed", r["message"])
# off-origin host: vertices are local, instances world, bounds kept apart
t3 = fresh()
graph(t3, "Off", GEO_OUT, [dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Vertices": 4, "Radius": 1.0}), dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints"), dict(id="join", blIdname="GeometryNodeJoinGeometry")],
[("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "join", "Geometry"), ("circle", "Mesh", "join", "Geometry"), ("join", "Geometry", "gout", "Geometry")])
ok(t3, action="createObject", objectType="MESH", name="OffHost", location=[100, 0, 0]); ok(t3, action="addModifier", objectName="OffHost", modifierName="GN", treeKey="Off")
ev = evaluated(t3, "OffHost")
check("local vertex bounds and world instance bounds are separate", max(ev["vertexBoundsMax"]) <= 1.01 and ev["instanceBoundsMin"][0] >= 98.9 and ev["instanceCount"] == 4, ev)
# non-finite positions are flagged
ok(t3, action="createNodeGroup", treeKey="Inf", interface=GEO_OUT)
for i in range(6): ok(t3, action="addNode", treeKey="Inf", id="s%d" % i, blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": 1e9, "Vector": [1, 1, 1]}) if i == 0 else ok(t3, action="addNode", treeKey="Inf", id="s%d" % i, blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": 1e9})
ok(t3, action="addNode", treeKey="Inf", id="cube", blIdname="GeometryNodeMeshCube"); ok(t3, action="addNode", treeKey="Inf", id="set", blIdname="GeometryNodeSetPosition")
for i in range(1, 6): ok(t3, action="linkSockets", treeKey="Inf", fromNode="s%d" % (i - 1), fromSocket="Vector", toNode="s%d" % i, toSocket="Vector")
ok(t3, action="linkSockets", treeKey="Inf", fromNode="cube", fromSocket="Mesh", toNode="set", toSocket="Geometry"); ok(t3, action="linkSockets", treeKey="Inf", fromNode="s5", fromSocket="Vector", toNode="set", toSocket="Position"); ok(t3, action="linkSockets", treeKey="Inf", fromNode="set", fromSocket="Geometry", toNode="gout", toSocket="Geometry")
ok(t3, action="createObject", objectType="MESH", name="InfHost"); ok(t3, action="addModifier", objectName="InfHost", treeKey="Inf")
ev = evaluated(t3, "InfHost")
check("non-finite evaluated positions are flagged and not published", ev["nonFinite"] and ev["vertexLocations"] == [] and json.dumps(ev, allow_nan=False), ev)
# ---- removal, unlinking, cycles (cut 2)
import time
t4 = fresh()
graph(t4, "R", GEO_OUT + [{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 4}],
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Radius": 1.0}), dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints"),
dict(id="m1", blIdname="ShaderNodeMath"), dict(id="m2", blIdname="ShaderNodeMath")],
[("gin", "Count", "circle", "Vertices"), ("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry"), ("m1", "Value", "m2", "Value")])
host(t4, "RHost", "R")
check("graph evaluates before removal", evaluated(t4, "RHost")["instanceCount"] == 4)
for src, dst in (("m2", "m1"), ("m1", "m1")):
r = act(t4, action="linkSockets", treeKey="R", fromNode=src, fromSocket="Value", toNode=dst, toSocket="Value_001"); check("cycle %s->%s refused" % (src, dst), r["status"] == "failed" and "cycle" in r["message"], r["message"])
check("refused cycle left the link set alone", len(bpy.data.node_groups["R"].links) == 5)
r = ok(t4, action="unlinkSockets", treeKey="R", fromNode="m1", fromSocket="Value", toNode="m2", toSocket="Value")
check("unlinked exactly one link", len(bpy.data.node_groups["R"].links) == 4, r["message"])
r = ok(t4, action="unlinkSockets", treeKey="R", toNode="m2", toSocket="Value"); check("unlink of nothing is accepted", "Removed 0" in r["message"], r["message"])
ok(t4, action="unlinkSockets", treeKey="R", toNode="iop", toSocket="Instance")
check("unlinking the instance changes the result", evaluated(t4, "RHost")["instanceCount"] == 0, evaluated(t4, "RHost"))
ok(t4, action="linkSockets", treeKey="R", fromNode="cube", fromSocket="Mesh", toNode="iop", toSocket="Instance")
check("relinked", evaluated(t4, "RHost")["instanceCount"] == 4)
links_before = len(bpy.data.node_groups["R"].links)
ok(t4, action="removeNode", treeKey="R", id="cube")
check("removeNode drops the node and its links", not any(n.get("brokkr_id") == "cube" for n in bpy.data.node_groups["R"].nodes) and len(bpy.data.node_groups["R"].links) == links_before - 1)
r = act(t4, action="removeNode", treeKey="R", id="gout"); check("group output node stays", r["status"] == "failed", r["message"])
check("removeNode of an absent id is accepted", act(t4, action="removeNode", treeKey="R", id="cube")["status"] == "accepted")
ok(t4, action="removeModifier", objectName="RHost", modifierName="GN")
check("removeModifier removes it; evaluated block gone", len(bpy.data.objects["RHost"].modifiers) == 0 and evaluated(t4, "RHost") is None)
bpy.data.objects["RHost"].modifiers.new("Sub", "SUBSURF")
r = act(t4, action="removeModifier", objectName="RHost", modifierName="Sub"); check("non-Geometry-Nodes modifier is not removed", r["status"] == "failed" and len(bpy.data.objects["RHost"].modifiers) == 1, r["message"])
# ---- cycle detection on a long chain (bpy hands out a fresh wrapper per access; id() would be reused)
t5 = fresh()
ok(t5, action="createNodeGroup", treeKey="C", interface=GEO_OUT)
ids = ["c%d" % i for i in range(60)]
for i in ids: act(t5, action="addNode", treeKey="C", id=i, blIdname="ShaderNodeMath")
for a_, b_ in zip(ids, ids[1:]): act(t5, action="linkSockets", treeKey="C", fromNode=a_, fromSocket="Value", toNode=b_, toSocket="Value")
for a_, b_ in zip(ids[:-2], ids[2:]): act(t5, action="linkSockets", treeKey="C", fromNode=a_, fromSocket="Value", toNode=b_, toSocket="Value_001")
before = len(bpy.data.node_groups["C"].links)
t0 = time.time()
refused = [act(t5, action="linkSockets", treeKey="C", fromNode=ids[-1], fromSocket="Value", toNode=h, toSocket="Value_002")["status"] == "failed" for h in ids[:-1]]
R["cycle_probe_seconds"] = round(time.time() - t0, 3)
check("every closing link on a 60-node chain with skip links is refused", all(refused) and len(bpy.data.node_groups["C"].links) == before, (sum(refused), len(refused), R["cycle_probe_seconds"]))
# ---- fix-pass checks: muted state survives a refused link; hand-made Unicode modifier names are removable
t6 = fresh()
graph(t6, "Mu", GEO_OUT, [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="m", blIdname="ShaderNodeMath"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")], [("cube", "Mesh", "iop", "Points")])
for l in bpy.data.node_groups["Mu"].links:
if l.to_node.get("brokkr_id") == "iop": l.is_muted = True
r = act(t6, action="linkSockets", treeKey="Mu", fromNode="m", fromSocket="Value", toNode="iop", toSocket="Points")
muted = [l.is_muted for l in bpy.data.node_groups["Mu"].links if l.to_node.get("brokkr_id") == "iop"]
check("refused link restores the displaced link's muted state", r["status"] == "failed" and muted == [True], (r["message"], muted))
ok(t6, action="createObject", objectType="MESH", name="U")
bpy.data.objects["U"].modifiers.new("G\u00e9 x", "NODES")
r = act(t6, action="removeModifier", objectName="U", modifierName="G\u00e9 x")
check("hand-made Unicode modifier name is removable", r["status"] == "accepted" and len(bpy.data.objects["U"].modifiers) == 0, r["message"])
# ---- cost: a heavy graph must not stall capture
def heavy(n, instanced):
tt = fresh()
nodes = [dict(id="grid", blIdname="GeometryNodeMeshGrid", inputs={"Vertices X": n, "Vertices Y": n})]
links = []
if instanced:
nodes += [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")]
links = [("grid", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")]
else:
links = [("grid", "Mesh", "gout", "Geometry")]
graph(tt, "Heavy", GEO_OUT, nodes, links); host(tt, "HeavyHost", "Heavy")
t0 = time.time(); snap = tt.capture_snapshot(bpy.context); dt = time.time() - t0
return dt, [o for o in snap["objects"] if o["name"] == "HeavyHost"][0]["evaluated"]
dt, ev = heavy(2000, True)
R["capture_2000_grid_instanced_seconds"] = round(dt, 3)
check("2000x2000 instanced grid: capture is bounded", dt < 3.0 and ev["instancesComplete"] is False and ev["instanceCount"] <= 100000, (round(dt, 2), ev["instanceCount"], ev["instancesComplete"]))
dt, ev = heavy(2000, False)
R["capture_2000_grid_mesh_seconds"] = round(dt, 3)
check("2000x2000 mesh grid: counts only, bounded", dt < 3.0 and ev["vertexCount"] == 4000000 and ev["positionsSkipped"], (round(dt, 2), ev["vertexCount"]))
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)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,178 @@
"""Recreate geometry-node setups through the Brokkr scene/object actions and compare with the native result.
BROKKR_ADDON=<path to surfaces/blender/brokkr_bridge> \
blender -b --factory-startup --python gn_recreation.py -- result.json
Each setup is built twice: natively (geometry-nodes tree via bpy, evaluated) and through
BrokkrBlenderTarget.execute_command using only the actions that exist. Prints GAP lines where an
action that would be needed is refused or has no effect. Not part of the unittest run (needs Blender).
"""
import bpy, sys, os, json, types, math, random
from pathlib import Path
from mathutils import Vector
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_result.json"
R = {"blender": bpy.app.version_string, "setups": {}, "gaps": {}}
def reset():
bpy.ops.wm.read_factory_settings(use_empty=True)
return bt.BrokkrBlenderTarget(bpy)
def act(t, **cmd):
cmd.setdefault("commandId", "c%d" % act.n); act.n += 1
return t.execute_command(bpy.context, cmd)
act.n = 0
def gap(key, **detail):
R["gaps"][key] = detail; print("GAP", key, json.dumps(detail), flush=True)
# --- native helpers ---------------------------------------------------------------------
def tree(name, extra_out=True):
t = bpy.data.node_groups.new(name, "GeometryNodeTree")
t.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
gout = t.nodes.new("NodeGroupOutput")
return t, gout
def host(name, gtree, mesh=None):
me = mesh or bpy.data.meshes.new(name)
ob = bpy.data.objects.new(name, me); bpy.context.scene.collection.objects.link(ob)
m = ob.modifiers.new("GN", "NODES"); m.node_group = gtree
return ob
def evaluated_instances(ob):
dg = bpy.context.evaluated_depsgraph_get(); dg.update()
return [tuple(round(c, 5) for c in inst.matrix_world.translation) for inst in dg.object_instances if inst.is_instance]
def evaluated_verts(ob):
dg = bpy.context.evaluated_depsgraph_get(); dg.update()
me = ob.evaluated_get(dg).to_mesh()
v = [tuple(round(c, 5) for c in p.co) for p in me.vertices]; ob.evaluated_get(dg).to_mesh_clear(); return v
def brokkr_positions(prefix):
bpy.context.view_layer.update() # matrix_world of the last-edited object is stale until then
return sorted(tuple(round(c, 5) for c in o.matrix_world.translation) for o in bpy.data.objects if o.name.startswith(prefix))
# =========================================================================================
# 1. Ring of instances (essentials-style array: Mesh Circle -> Instance on Points)
# =========================================================================================
t = reset(); N, RAD = 8, 2.0
g, gout = tree("Ring")
circ = g.nodes.new("GeometryNodeMeshCircle"); circ.inputs["Vertices"].default_value = N; circ.inputs["Radius"].default_value = RAD
cube = g.nodes.new("GeometryNodeMeshCube"); cube.inputs["Size"].default_value = (0.3, 0.3, 0.3)
iop = g.nodes.new("GeometryNodeInstanceOnPoints")
g.links.new(circ.outputs["Mesh"], iop.inputs["Points"]); g.links.new(cube.outputs["Mesh"], iop.inputs["Instance"])
g.links.new(iop.outputs["Instances"], gout.inputs["Geometry"])
ob = host("RingHost", g)
native = sorted(evaluated_instances(ob))
bpy.data.objects.remove(ob)
acts = 0; fails = 0
for i in range(N):
a = 2 * math.pi * i / N
r1 = act(t, action="createObject", objectType="MESH", name="Ring.%d" % i, size=0.3); acts += 1
r2 = act(t, action="setObjectTransform", targetObjectName="Ring.%d" % i, location=[RAD * math.cos(a), RAD * math.sin(a), 0]); acts += 1
fails += (r1["status"] != "accepted") + (r2["status"] != "accepted")
bro = brokkr_positions("Ring.")
ok = native == bro
R["setups"]["ring"] = {"native_instances": len(native), "brokkr_objects": len(bro), "positions_equal": ok, "actions": acts, "failed_actions": fails}
print("SETUP ring", R["setups"]["ring"]); print("RING native", native); print("RING brokkr", bro)
# size parity
cube_dim = bpy.data.objects["Ring.0"].dimensions[:]
R["setups"]["ring"]["brokkr_object_dimensions"] = [round(d, 4) for d in cube_dim]
# live-ness: the native ring re-evaluates when Count changes; the Brokkr one needs N more actions.
# GAP: any node-graph action
for aname, extra in (("createNodeGroup", {}), ("addModifier", {"modifierType": "NODES"}), ("addNode", {}), ("setModifierInput", {"socket": "Socket_2", "value": 12})):
r = act(t, action=aname, targetObjectName="Ring.0", **extra)
R["gaps"].setdefault("no_node_actions", {})[aname] = [r["status"], r["message"]]
print("GAP no_node_actions", R["gaps"]["no_node_actions"])
# GAP: instances need one object each; no instancing/collection-instance action; mesh primitive is cube only
gap("no_primitive_choice", createObject_MESH="always primitive_cube_add; 'size' only",
repro="createObject objectType=MESH name=C -> verts=%d" % len(bpy.data.objects["Ring.0"].data.vertices))
# GAP: setObjectCustomProperty cannot drive a modifier input
g2, go2 = tree("Param")
circ2 = g2.nodes.new("GeometryNodeMeshCircle")
g2.interface.new_socket("N", in_out="INPUT", socket_type="NodeSocketInt")
gi = g2.nodes.new("NodeGroupInput"); g2.links.new(gi.outputs["N"], circ2.inputs["Vertices"])
g2.links.new(circ2.outputs["Mesh"], go2.inputs["Geometry"])
hob = host("ParamHost", g2)
ident = [s.identifier for s in g2.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"][0]
getattr(hob.modifiers["GN"].properties.inputs, ident).__setattr__("value", 5) # inputs[ident] is a raw IDPropertyGroup with no .value
before = len(evaluated_verts(hob))
r = act(t, action="setObjectCustomProperty", targetObjectName="ParamHost", propertyName=ident, value=12)
after = len(evaluated_verts(hob))
snap = t.capture_snapshot(bpy.context)
ho = [o for o in snap["objects"] if o["name"] == "ParamHost"][0]
gap("custom_property_does_not_drive_modifier", receipt=r["status"], verts_before=before, verts_after=after, identifier=ident,
snapshot_modifier=ho["modifiers"], snapshot_mesh=ho["mesh"],
repro="setObjectCustomProperty on a GN host object -> accepted, evaluated vertex count unchanged; snapshot reports base mesh counts and no modifier inputs")
bpy.data.objects.remove(hob)
# =========================================================================================
# 2. Boundary step (example 2): pull points outside radius B back to the circle
# =========================================================================================
t = reset(); B = 0.5; XS = [0.0, 2.0, 4.0]
g, gout = tree("Boundary")
line = g.nodes.new("GeometryNodeMeshLine"); line.inputs["Count"].default_value = 3
line.inputs["Start Location"].default_value = (0, 0, 0); line.inputs["Offset"].default_value = (2, 0, 0)
pos = g.nodes.new("GeometryNodeInputPosition")
dist = g.nodes.new("ShaderNodeVectorMath"); dist.operation = "LENGTH"
cmp_ = g.nodes.new("FunctionNodeCompare"); cmp_.data_type = "FLOAT"; cmp_.operation = "GREATER_THAN"; cmp_.inputs[1].default_value = B
nrm = g.nodes.new("ShaderNodeVectorMath"); nrm.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], dist.inputs[0]); g.links.new(dist.outputs["Value"], cmp_.inputs[0])
g.links.new(pos.outputs[0], nrm.inputs[0]); g.links.new(nrm.outputs[0], sc.inputs[0])
g.links.new(line.outputs["Mesh"], sp.inputs["Geometry"]); g.links.new(cmp_.outputs["Result"], sp.inputs["Selection"])
g.links.new(sc.outputs[0], sp.inputs["Position"]); g.links.new(sp.outputs["Geometry"], gout.inputs["Geometry"])
ob = host("BoundaryHost", g); native = sorted(evaluated_verts(ob)); bpy.data.objects.remove(ob)
acts = 0
for i, x in enumerate(XS):
v = Vector((x, 0, 0)); nv = v if v.length <= B else v.normalized() * B # the *client* does the field math
act(t, action="createObject", objectType="EMPTY", name="P.%d" % i); act(t, action="setObjectTransform", targetObjectName="P.%d" % i, location=list(nv)); acts += 2
bro = brokkr_positions("P.")
R["setups"]["boundary_step"] = {"native": native, "brokkr": bro, "equal": native == bro, "actions": acts,
"note": "equality needs the client to evaluate the field; Brokkr only carries the result"}
print("SETUP boundary_step", R["setups"]["boundary_step"])
gap("no_field_evaluation", repro="no action takes an expression/field; boundary_step's per-element rule must be computed by the caller and sent as N setObjectTransform")
# =========================================================================================
# 3. Seeded scatter (grass-tuft front half): Distribute Points on Faces -> Instance on Points
# =========================================================================================
t = reset(); SEED = 7
g, gout = tree("Scatter")
grid = g.nodes.new("GeometryNodeMeshGrid"); grid.inputs["Size X"].default_value = 3; grid.inputs["Size Y"].default_value = 3
dis = g.nodes.new("GeometryNodeDistributePointsOnFaces"); dis.distribute_method = "RANDOM"
dis.inputs["Density"].default_value = 4; dis.inputs["Seed"].default_value = SEED
cone = g.nodes.new("GeometryNodeMeshCone"); cone.inputs["Radius Bottom"].default_value = 0.05; cone.inputs["Depth"].default_value = 0.4
iop = g.nodes.new("GeometryNodeInstanceOnPoints")
g.links.new(grid.outputs["Mesh"], dis.inputs["Mesh"]); g.links.new(dis.outputs["Points"], iop.inputs["Points"])
g.links.new(cone.outputs["Mesh"], iop.inputs["Instance"]); g.links.new(iop.outputs["Instances"], gout.inputs["Geometry"])
ob = host("ScatterHost", g); native = sorted(evaluated_instances(ob))
# what Brokkr can see of that object
snap = t.capture_snapshot(bpy.context); so = [o for o in snap["objects"] if o["name"] == "ScatterHost"][0]
gap("snapshot_not_evaluated", brokkr_sees=so["mesh"], modifiers=so["modifiers"], evaluated_instances=len(native),
repro="host object with Distribute->InstanceOnPoints: snapshot mesh.vertexCount=0, no instance count, no positions")
# (a) replay the native points through actions (needs a side channel that is not Brokkr)
bpy.data.objects.remove(ob); acts = 0
for i, p in enumerate(native):
act(t, action="createObject", objectType="MESH", name="S.%d" % i, size=0.1); act(t, action="setObjectTransform", targetObjectName="S.%d" % i, location=list(p)); acts += 2
R["setups"]["scatter_replay"] = {"native_instances": len(native), "equal": brokkr_positions("S.") == native, "actions": acts,
"note": "positions were read with bpy depsgraph, not through any Brokkr surface"}
# (b) recreate from the seed with the caller's own RNG: does not match Blender's point hash
random.seed(SEED)
mine = sorted((round(random.uniform(-1.5, 1.5), 5), round(random.uniform(-1.5, 1.5), 5), 0.0) for _ in native)
R["setups"]["scatter_own_rng"] = {"count_equal": len(mine) == len(native), "positions_equal": mine == [(a, b, 0.0) for a, b, _ in native] or mine == native}
print("SETUP scatter", R["setups"]["scatter_replay"], R["setups"]["scatter_own_rng"])
gap("no_seed_parity", repro="Distribute Points on Faces seed=7 density=4 on a 3x3 grid: positions are Blender's hash; only reading them back (not offered) reproduces them")
# --- render check: the Brokkr scene still renders under the existing actions
act(t, action="createObject", objectType="CAMERA", name="Cam", location=[0, -6, 4]); act(t, action="setCamera", targetObjectName="Cam", active=True, lens=35)
os.makedirs(os.environ.get("BROKKR_RUN", "."), exist_ok=True)
t.configure_action_dirs(os.environ.get("BROKKR_RUN", "."), os.environ.get("BROKKR_RUN", "."))
r = act(t, action="renderStill", outputPath="scatter_replay.png", engine="BLENDER_WORKBENCH", resolutionX=64, resolutionY=64, cameraName="Cam")
R["setups"]["scatter_replay"]["render"] = [r["status"], r["message"]]
json.dump(R, open(out, "w"), indent=1, default=str)
print("DONE", out)

View file

@ -0,0 +1,141 @@
{
"blender": "5.2.3 LTS",
"setups": {
"ring": {
"native_instances": 8,
"brokkr_objects": 8,
"positions_equal": true,
"actions": 16,
"failed_actions": 0,
"brokkr_object_dimensions": [
0.3,
0.3,
0.3
]
},
"boundary_step": {
"native": [
[
0.0,
0.0,
0.0
],
[
0.5,
0.0,
0.0
],
[
0.5,
0.0,
0.0
]
],
"brokkr": [
[
0.0,
0.0,
0.0
],
[
0.5,
0.0,
0.0
],
[
0.5,
0.0,
0.0
]
],
"equal": true,
"actions": 6,
"note": "equality needs the client to evaluate the field; Brokkr only carries the result"
},
"scatter_replay": {
"native_instances": 38,
"equal": true,
"actions": 76,
"note": "positions were read with bpy depsgraph, not through any Brokkr surface",
"render": [
"accepted",
"Rendered still to scatter_replay.png."
]
},
"scatter_own_rng": {
"count_equal": true,
"positions_equal": false
}
},
"gaps": {
"no_node_actions": {
"createNodeGroup": [
"failed",
"Unsupported Blender command action: createNodeGroup"
],
"addModifier": [
"failed",
"Unsupported Blender command action: addModifier"
],
"addNode": [
"failed",
"Unsupported Blender command action: addNode"
],
"setModifierInput": [
"failed",
"Unsupported Blender command action: setModifierInput"
]
},
"no_primitive_choice": {
"createObject_MESH": "always primitive_cube_add; 'size' only",
"repro": "createObject objectType=MESH name=C -> verts=8"
},
"custom_property_does_not_drive_modifier": {
"receipt": "accepted",
"verts_before": 5,
"verts_after": 5,
"identifier": "Socket_1",
"snapshot_modifier": [
{
"name": "GN",
"type": "NODES",
"showViewport": true,
"showRender": true
}
],
"snapshot_mesh": {
"vertexCount": 0,
"edgeCount": 0,
"polygonCount": 0,
"uvLayerCount": 0,
"shapeKeyCount": 0
},
"repro": "setObjectCustomProperty on a GN host object -> accepted, evaluated vertex count unchanged; snapshot reports base mesh counts and no modifier inputs"
},
"no_field_evaluation": {
"repro": "no action takes an expression/field; boundary_step's per-element rule must be computed by the caller and sent as N setObjectTransform"
},
"snapshot_not_evaluated": {
"brokkr_sees": {
"vertexCount": 0,
"edgeCount": 0,
"polygonCount": 0,
"uvLayerCount": 0,
"shapeKeyCount": 0
},
"modifiers": [
{
"name": "GN",
"type": "NODES",
"showViewport": true,
"showRender": true
}
],
"evaluated_instances": 38,
"repro": "host object with Distribute->InstanceOnPoints: snapshot mesh.vertexCount=0, no instance count, no positions"
},
"no_seed_parity": {
"repro": "Distribute Points on Faces seed=7 density=4 on a 3x3 grid: positions are Blender's hash; only reading them back (not offered) reproduces them"
}
}
}

View file

@ -0,0 +1,722 @@
"""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
import unittest.mock
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."""
counter = 1000
def as_pointer(self):
return self.pointer
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
Node.counter += 1
self.pointer = Node.counter
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")]
for socket in self.inputs + self.outputs:
socket.node = self
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 Proxy:
"""A fresh wrapper per access, as bpy hands out: equal by pointer, with a different id() each time."""
def __init__(self, node):
self._node = node
def as_pointer(self):
return self._node.pointer
def __getattr__(self, name):
return getattr(self._node, name)
def __eq__(self, other):
return getattr(other, "pointer", None) == self._node.pointer or (isinstance(other, Proxy) and other._node is self._node)
__hash__ = None
class Links(list):
def __init__(self, valid=True):
super().__init__()
self.valid = valid
def new(self, a, b):
for old in [l for l in self if l.to_socket is b]: # a single-link input loses its old link, as in Blender
list.remove(self, old)
link = NS(from_socket=a, to_socket=b, from_node=Proxy(a.node), to_node=Proxy(b.node), is_muted=False, is_valid=self.valid(a, b) if callable(self.valid) else 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):
refuses = False
def get(self, name):
return next((m for m in self if m.name == name), None)
def remove(self, modifier):
list.remove(self, modifier)
def new(self, name, kind):
if self.refuses:
return None # Blender returns None for object types without modifiers
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")
# access rules, pinned by literals
def test_each_denied_node_family_is_refused_alone(self):
tree = self.group()
denied = ["GeometryNodeImportOBJ", "GeometryNodeExportPLY", "ShaderNodeScript", "GeometryNodeBake", "GeometryNodeGroup",
"GeometryNodeSimulationInput", "GeometryNodeRepeatInput", "GeometryNodeForeachGeometryElementInput",
"GeometryNodeViewer", "ShaderNodeTexImage", "ShaderNodeTexEnvironment", "GeometryNodeImageTexture",
"GeometryNodeInputImage", "ShaderNodeTexIES"]
before = len(tree.nodes)
for idname in denied:
self.refused(action="addNode", treeKey="G", id="x", blIdname=idname)
self.assertEqual(len(tree.nodes), before)
def test_legitimate_node_families_are_allowed(self):
for i, idname in enumerate(["ShaderNodeMath", "GeometryNodeMeshGrid", "FunctionNodeCompare", "GeometryNodeSetPosition", "ShaderNodeTexNoise"]):
self.group() if i == 0 else None
self.accepted(action="addNode", treeKey="G", id=f"n{i}", blIdname=idname)
def test_idname_must_be_a_whole_known_prefixed_name(self):
self.group()
for bad in ("Foo", "ShaderNode", "xShaderNodeMath", "ShaderNodeMath\n", "ShaderNode Math", "ShaderNode_Math", "CompositorNodeMath", "ShaderNodeMath;", "", None, 5):
self.refused(action="addNode", treeKey="G", id="x", blIdname=bad)
def test_node_cap_is_exactly_500(self):
tree = self.group()
for _ in range(497):
tree.nodes.new("ShaderNodeMath")
self.assertEqual(len(tree.nodes), 499)
self.accepted(action="addNode", treeKey="G", id="last", blIdname="ShaderNodeMath")
self.assertEqual(len(tree.nodes), 500)
self.refused(action="addNode", treeKey="G", id="over", blIdname="ShaderNodeMath")
def test_interface_cap_is_exactly_64(self):
def sockets(count):
return [{"inOut": "INPUT", "name": f"s{i}", "type": "Int"} for i in range(count)]
self.accepted(action="createNodeGroup", treeKey="A", interface=sockets(64))
self.refused(action="createNodeGroup", treeKey="B", interface=sockets(65))
self.refused(action="createNodeGroup", treeKey="C", interface=[])
def test_int_limit_is_exactly_4096(self):
self.group()
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for value in (4096, -4096):
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=value)
for value in (4097, -4097):
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=value)
self.accepted(action="addNode", treeKey="G", id="ok", blIdname="ShaderNodeMath", inputs={"A": 4097}) # a float input has no int cap
def test_value_limit_is_exactly_one_billion(self):
self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath", inputs={"A": 1e9})
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath", inputs={"A": -1e9})
self.refused(action="addNode", treeKey="G", id="c", blIdname="ShaderNodeMath", inputs={"A": 1000000001})
self.refused(action="addNode", treeKey="G", id="d", blIdname="ShaderNodeMath", inputs={"Size": [0, 0, -1000000001]})
self.accepted(action="addNode", treeKey="G", id="e", blIdname="ShaderNodeMath", location=[1e9, -1e9])
self.refused(action="addNode", treeKey="G", id="f", blIdname="ShaderNodeMath", location=[1000000001, 0])
def test_modifier_cap_is_exactly_8(self):
self.group()
for i in range(8):
self.accepted(action="addModifier", objectName="Host", modifierName=f"m{i}", treeKey="G")
self.refused(action="addModifier", objectName="Host", modifierName="m8", treeKey="G")
self.accepted(action="addModifier", objectName="Host", modifierName="m3", treeKey="G") # reuse is not growth
def test_location_must_be_two_finite_numbers(self):
tree = self.group()
for bad in ([1, 2, 3], "ab", 5, [1, float("nan")], [1, "2"], [True, 1]):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", location=bad)
self.assertEqual(len(tree.nodes), 2)
def test_readonly_node_property_is_refused_and_untouched(self):
self.group()
with unittest.mock.patch.dict(Node.PROPS, {"flag": NS(type="BOOLEAN", is_readonly=True)}):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", properties={"flag": True})
self.assertEqual(len(self.bpy.data.node_groups["G"].nodes), 2)
def test_input_without_a_default_or_with_a_link_is_refused(self):
tree = self.group()
original = Nodes.new
def linked(nodes, idname):
node = original(nodes, idname)
node.inputs[0].is_linked = True
return node
with unittest.mock.patch.object(Nodes, "new", linked):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", inputs={"A": 1})
self.assertEqual(len(tree.nodes), 2)
def bare(nodes, idname):
node = original(nodes, idname)
del node.inputs[0].default_value
return node
with unittest.mock.patch.object(Nodes, "new", bare):
self.refused(action="addNode", treeKey="G", id="y", blIdname="ShaderNodeMath", inputs={"A": 1})
self.assertEqual(len(tree.nodes), 2)
def test_create_node_group_that_fails_midway_leaves_no_group(self):
original = Nodes.new
def failing(nodes, idname):
if idname == "NodeGroupOutput":
raise RuntimeError("boom")
return original(nodes, idname)
with unittest.mock.patch.object(Nodes, "new", failing):
self.refused(action="createNodeGroup", treeKey="Half", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertNotIn("Half", self.bpy.data.node_groups)
self.accepted(action="createNodeGroup", treeKey="Half", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
def test_add_modifier_will_not_take_over_a_foreign_modifier(self):
self.group()
host = self.bpy.data.objects["Host"]
foreign = host.modifiers.new("Mine", "SUBSURF")
self.refused(action="addModifier", objectName="Host", modifierName="Mine", treeKey="G")
self.assertIsNone(foreign.node_group)
self.assertEqual(len(host.modifiers), 1)
def test_set_modifier_input_only_drives_geometry_nodes_modifiers(self):
tree = self.group()
host = self.bpy.data.objects["Host"]
foreign = host.modifiers.new("Mine", "SUBSURF")
foreign.node_group = tree
self.refused(action="setModifierInput", objectName="Host", modifierName="Mine", socket="Count", value=1)
self.assertEqual(foreign.properties.inputs.values, {})
self.assertEqual(host.tagged, 0)
def test_blender_capabilities_include_everything_the_daemon_advertises_for_this_cut(self):
# brokkr-daemon/src/main.rs lists the same names (and asserts them in its own test); a capability
# added to one side only fails one of the two tests.
for name in ("asset.import.gltf", "camera.write", "light.write", "world.write", "render.still", "nodegraph.geometry.write", "geometry.evaluated.read"):
self.assertIn(name, bt.CAPABILITIES)
# 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")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
tree.nodes[-1].inputs[3].enabled = False
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", 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.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
receipt = self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.assertIn("not valid", receipt["message"]) # past the cycle check: Blender was asked and said no
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)
# removal, unlinking, cycles
def chain(self):
tree = self.group()
for node_id in ("a", "b", "c"):
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
return tree
def test_cycles_and_self_links_are_refused_and_change_nothing(self):
tree = self.chain()
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
before = list(tree.links)
for src, dst in (("c", "a"), ("b", "a"), ("a", "a"), ("c", "b")):
receipt = self.refused(action="linkSockets", treeKey="G", fromNode=src, fromSocket="Out", toNode=dst, toSocket="Size")
self.assertIn("cycle", receipt["message"])
self.assertEqual(list(tree.links), before)
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="Size") # a forward skip is no cycle
def test_cycle_detection_survives_fresh_node_wrappers_on_long_chains(self):
tree = self.group()
ids = [f"n{i}" for i in range(12)]
for node_id in ids:
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for a, b in zip(ids, ids[1:]):
self.accepted(action="linkSockets", treeKey="G", fromNode=a, fromSocket="Out", toNode=b, toSocket="A")
# a diamond ladder: every rung reachable two ways
for a, b in zip(ids[:-2], ids[2:]):
self.accepted(action="linkSockets", treeKey="G", fromNode=a, fromSocket="Out", toNode=b, toSocket="Size")
count = len(tree.links)
for head in ids[:-1]:
self.refused(action="linkSockets", treeKey="G", fromNode=ids[-1], fromSocket="Out", toNode=head, toSocket="Size")
self.assertEqual(len(tree.links), count)
def test_cycle_through_a_later_branch_is_found(self):
tree = self.group()
for node_id in "abcd":
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for src, dst in (("a", "b"), ("a", "c"), ("c", "d")):
self.accepted(action="linkSockets", treeKey="G", fromNode=src, fromSocket="Out", toNode=dst, toSocket="A")
self.refused(action="linkSockets", treeKey="G", fromNode="d", fromSocket="Out", toNode="a", toSocket="A")
def test_cycle_walk_visits_each_node_once_on_a_ladder(self):
tree = self.group()
ids = [f"n{i}" for i in range(40)]
for node_id in ids:
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for a, b in zip(ids, ids[1:]):
tree.links.new(tree.nodes[2 + ids.index(a)].outputs[0], tree.nodes[2 + ids.index(b)].inputs[0])
for a, b in zip(ids, ids[2:]):
tree.links.new(tree.nodes[2 + ids.index(a)].outputs[0], tree.nodes[2 + ids.index(b)].inputs[1])
calls = []
real = na.node_key
with unittest.mock.patch.object(na, "node_key", lambda n: calls.append(1) or real(n)):
self.assertFalse(na.reaches(tree, tree.nodes[2], NS(pointer=-1, as_pointer=lambda: -1)))
self.assertLess(len(calls), 4 * len(tree.links))
def test_remove_node(self):
tree = self.chain()
self.accepted(action="removeNode", treeKey="G", id="b")
self.assertEqual([n.get("brokkr_id") for n in tree.nodes], ["gin", "gout", "a", "c"])
again = self.accepted(action="removeNode", treeKey="G", id="b")
self.assertIn("already absent", again["message"])
for keep in ("gin", "gout"):
self.refused(action="removeNode", treeKey="G", id=keep)
self.refused(action="removeNode", treeKey="Nope", id="a")
self.refused(action="removeNode", treeKey="G", id="a b")
def test_unlink_removes_only_the_matching_links(self):
tree = self.chain()
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="Size")
self.refused(action="unlinkSockets", treeKey="G", fromNode="zzz", fromSocket="Out", toNode="c", toSocket="A")
self.assertIn("Removed 0", self.accepted(action="unlinkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")["message"])
self.assertEqual(len(tree.links), 2)
self.assertIn("Removed 1", self.accepted(action="unlinkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")["message"])
self.assertEqual(len(tree.links), 1)
self.accepted(action="unlinkSockets", treeKey="G", toNode="c", toSocket="Size")
self.assertEqual(len(tree.links), 0)
self.refused(action="unlinkSockets", treeKey="G", fromNode="a", toNode="c", toSocket="A") # a source node needs its socket
def test_remove_modifier_takes_only_geometry_nodes_modifiers(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
host.modifiers.new("Sub", "SUBSURF")
tags = host.tagged
self.refused(action="removeModifier", objectName="Host", modifierName="Sub")
self.assertEqual([m.name for m in host.modifiers], ["GN", "Sub"])
self.accepted(action="removeModifier", objectName="Host", modifierName="GN")
self.assertEqual([m.name for m in host.modifiers], ["Sub"])
self.assertEqual(host.tagged, tags + 1)
self.assertIn("already absent", self.accepted(action="removeModifier", objectName="Host", modifierName="GN")["message"])
self.refused(action="removeModifier", objectName="Nope", modifierName="GN")
for bad in (["x"], "x" * 256, "a\0b", 5):
self.refused(action="removeModifier", objectName="Host", modifierName=bad)
host.modifiers.new("Gé x/y", "NODES") # made by hand: any name Blender allows can be removed
self.accepted(action="removeModifier", objectName="Host", modifierName="Gé x/y")
self.assertEqual([m.name for m in host.modifiers], ["Sub"])
# setObjectCustomProperty on a Geometry Nodes host
def test_custom_property_naming_a_modifier_input_identifier_is_refused(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for name in ("Socket_1", "customProperties.Socket_1"):
receipt = self.refused(action="setObjectCustomProperty", targetObjectName="Host", propertyName=name, value=3)
self.assertIn("setModifierInput", receipt["message"])
self.assertFalse(hasattr(host, "custom"))
def test_custom_property_sharing_only_a_display_name_is_an_ordinary_property(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="Count", value=3)
self.assertEqual(host.custom, ("Count", 3))
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))
# review fixes
def test_refused_link_restores_the_link_it_displaced(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="addNode", treeKey="G", id="c", blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
tree.links.valid = lambda a, b: a is not tree.nodes[3].outputs[0] # b.Out -> anything is invalid
self.refused(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
self.assertEqual([(l.from_socket, l.to_socket) for l in tree.links], [(tree.nodes[2].outputs[0], tree.nodes[4].inputs[0])])
def test_refused_link_restores_the_displaced_link_muted_state(self):
tree = self.group()
for node_id in ("a", "b", "c"):
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
tree.links[0].is_muted = True
tree.links.valid = lambda a, b: False
self.refused(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
self.assertEqual([l.is_muted for l in tree.links], [True])
def test_ints_are_capped_and_patterns_reject_trailing_newlines(self):
self.group()
self.accepted(action="addNode", treeKey="G", id="ok", blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="a\n", blIdname="ShaderNodeMath")
self.refused(action="createNodeGroup", treeKey="T\n", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.refused(action="createNodeGroup", treeKey="H", interface=[{"inOut": "INPUT", "name": "a", "type": "Int", "default": na.MAX_INT + 1}])
self.accepted(action="createNodeGroup", treeKey="H", interface=[{"inOut": "INPUT", "name": "a", "type": "Int", "default": na.MAX_INT}])
def test_modifier_on_an_object_type_without_modifiers_is_a_clear_refusal(self):
self.group()
host = self.bpy.data.objects["Host"]
host.modifiers.refuses = True
host.type = "CAMERA"
receipt = self.refused(action="addModifier", objectName="Host", treeKey="G")
self.assertIn("CAMERA", receipt["message"])
# snapshot
def snapshot_fixture(self, coords, instances):
host = self.bpy.data.objects["Host"]
host.modifiers.new("GN", "NODES")
class Verts(list):
def foreach_get(self_inner, name, buffer):
flat = [c for v in self_inner for c in v.co]
buffer[:] = type(buffer)("f", flat)
mesh = NS(vertices=Verts(NS(co=c) for c in coords), edges=[1], polygons=[])
evaluated = NS(to_mesh=lambda: mesh, to_mesh_clear=lambda: None)
host.evaluated_get = lambda dg: evaluated
self.bpy.context = NS(evaluated_depsgraph_get=lambda: NS(update=lambda: None, object_instances=instances))
return host
@staticmethod
def instance(parent, point, is_instance=True):
return NS(is_instance=is_instance, parent=NS(original=parent) if parent else None, matrix_world=NS(translation=point))
def evaluated(self, host):
return self.target._evaluated_snapshot(host, self.target._instance_scan())
def test_evaluated_snapshot_reports_counts_separate_bounds_and_cap(self):
host = self.bpy.data.objects["Host"]
n = bt.EVALUATED_POSITION_CAP + 2
other = Obj("other")
instances = [self.instance(host, (0.0, 5.0 + i, 0.0)) for i in range(n)] + [self.instance(other, (9.0, 9.0, 9.0)), self.instance(None, (8.0, 8.0, 8.0), False)]
host = self.snapshot_fixture([(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (2.0, 0.0, 0.0)], instances)
data = self.evaluated(host)
self.assertEqual((data["vertexCount"], data["edgeCount"], data["instanceCount"]), (3, 1, n))
self.assertEqual((data["vertexBoundsMin"], data["vertexBoundsMax"]), ([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]))
self.assertEqual((data["instanceBoundsMin"], data["instanceBoundsMax"]), ([0.0, 5.0, 0.0], [0.0, 5.0 + n - 1, 0.0]))
self.assertEqual((len(data["instanceLocations"]), len(data["vertexLocations"]), data["truncated"]), (bt.EVALUATED_POSITION_CAP, 3, True))
self.assertFalse(data["nonFinite"])
def test_non_finite_positions_are_flagged_not_published(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0), (float("inf"), 0.0, 0.0)], [])
data = self.evaluated(host)
self.assertTrue(data["nonFinite"])
self.assertEqual((data["vertexLocations"], data["vertexBoundsMin"], data["vertexBoundsMax"]), ([], [], []))
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [self.instance(host, (float("nan"), 0.0, 0.0))])
data = self.evaluated(host)
self.assertTrue(data["nonFinite"] and data["instanceLocations"] == [] and data["instanceBoundsMin"] == [])
def test_a_huge_evaluated_mesh_reports_counts_only(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0)] * 3, [])
with unittest.mock.patch.object(bt, "EVALUATED_VERTEX_LIMIT", 2):
data = self.evaluated(host)
self.assertEqual((data["vertexCount"], data["positionsSkipped"], data["vertexLocations"], data["vertexBoundsMin"]), (3, True, [], []))
def test_per_object_snapshots_reuse_the_one_scan(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [])
calls = []
get = self.bpy.context.evaluated_depsgraph_get
self.bpy.context.evaluated_depsgraph_get = lambda: calls.append(1) or get()
scan = self.target._instance_scan()
for _ in range(3):
self.target._evaluated_snapshot(host, scan)
self.assertEqual(len(calls), 1)
def test_instance_scan_stops_at_the_limit_and_says_so(self):
host = self.bpy.data.objects["Host"]
visited = []
def many():
for i in range(50):
visited.append(i)
yield self.instance(host, (float(i), 0.0, 0.0))
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], many())
with unittest.mock.patch.object(bt, "EVALUATED_INSTANCE_SCAN_LIMIT", 10):
scan = self.target._instance_scan()
data = self.target._evaluated_snapshot(host, scan)
self.assertEqual((data["instanceCount"], data["instancesComplete"]), (10, False))
other = Obj("Other")
other.modifiers.new("GN", "NODES")
other.evaluated_get = host.evaluated_get
self.assertIsNone(self.target._evaluated_snapshot(other, scan)["instanceCount"]) # never reached: unknown, not 0
self.assertLessEqual(len(visited), 11) # the generator is abandoned, not drained
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [self.instance(host, (1.0, 0.0, 0.0))])
self.assertTrue(self.evaluated(host)["instancesComplete"])
def test_object_without_modifiers_has_no_evaluated_block(self):
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], None))
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], (None, {}, True)))
if __name__ == "__main__":
unittest.main()