diff --git a/brokkr-daemon/src/main.rs b/brokkr-daemon/src/main.rs index e34d56a..3b05166 100644 --- a/brokkr-daemon/src/main.rs +++ b/brokkr-daemon/src/main.rs @@ -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 diff --git a/docs/provider-contract.md b/docs/provider-contract.md index bc30905..8c1132f 100644 --- a/docs/provider-contract.md +++ b/docs/provider-contract.md @@ -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 diff --git a/surfaces/blender/brokkr_bridge/blender_target.py b/surfaces/blender/brokkr_bridge/blender_target.py index f482de9..628c199 100644 --- a/surfaces/blender/brokkr_bridge/blender_target.py +++ b/surfaces/blender/brokkr_bridge/blender_target.py @@ -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, diff --git a/surfaces/blender/brokkr_bridge/node_actions.py b/surfaces/blender/brokkr_bridge/node_actions.py new file mode 100644 index 0000000..5210b86 --- /dev/null +++ b/surfaces/blender/brokkr_bridge/node_actions.py @@ -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 "" diff --git a/surfaces/blender/docs/node-actions.md b/surfaces/blender/docs/node-actions.md new file mode 100644 index 0000000..3458b95 --- /dev/null +++ b/surfaces/blender/docs/node-actions.md @@ -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`. diff --git a/surfaces/blender/tests/live/gn_actions.py b/surfaces/blender/tests/live/gn_actions.py new file mode 100644 index 0000000..1d9e329 --- /dev/null +++ b/surfaces/blender/tests/live/gn_actions.py @@ -0,0 +1,269 @@ +"""Live proof of the node-graph actions: rebuild three geometry-node setups through Brokkr actions only. + + BROKKR_ADDON= \ + 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) diff --git a/surfaces/blender/tests/live/gn_actions.result.json b/surfaces/blender/tests/live/gn_actions.result.json new file mode 100644 index 0000000..cdcc1f5 --- /dev/null +++ b/surfaces/blender/tests/live/gn_actions.result.json @@ -0,0 +1,1097 @@ +{ + "blender": "5.2.3 LTS", + "checks": [ + { + "name": "createNodeGroup Ring", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "addNode circle", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode cube", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode iop", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets Ring", + "ok": true, + "detail": "Linked circle.Mesh -> iop.Points." + }, + { + "name": "linkSockets Ring", + "ok": true, + "detail": "Linked cube.Mesh -> iop.Instance." + }, + { + "name": "linkSockets Ring", + "ok": true, + "detail": "Linked iop.Instances -> gout.Socket_0." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier Ring", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "ring instances equal native", + "ok": true, + "detail": { + "brokkr": [ + [ + 2.0, + 0.0, + 0.0 + ], + [ + 1.41421, + 1.41421, + 0.0 + ], + [ + -0.0, + 2.0, + 0.0 + ], + [ + -1.41421, + 1.41421, + 0.0 + ], + [ + -2.0, + -0.0, + 0.0 + ], + [ + -1.41421, + -1.41421, + 0.0 + ], + [ + 0.0, + -2.0, + 0.0 + ], + [ + 1.41421, + -1.41421, + 0.0 + ] + ], + "native": [ + [ + -2.0, + -0.0, + 0.0 + ], + [ + -1.41421, + -1.41421, + 0.0 + ], + [ + -1.41421, + 1.41421, + 0.0 + ], + [ + 0.0, + -2.0, + 0.0 + ], + [ + -0.0, + 2.0, + 0.0 + ], + [ + 1.41421, + -1.41421, + 0.0 + ], + [ + 1.41421, + 1.41421, + 0.0 + ], + [ + 2.0, + 0.0, + 0.0 + ] + ] + } + }, + { + "name": "createNodeGroup RingN", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0, input Count=Socket_1" + }, + { + "name": "addNode circle", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode cube", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode iop", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets RingN", + "ok": true, + "detail": "Linked gin.Socket_1 -> circle.Vertices." + }, + { + "name": "linkSockets RingN", + "ok": true, + "detail": "Linked circle.Mesh -> iop.Points." + }, + { + "name": "linkSockets RingN", + "ok": true, + "detail": "Linked cube.Mesh -> iop.Instance." + }, + { + "name": "linkSockets RingN", + "ok": true, + "detail": "Linked iop.Instances -> gout.Socket_0." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier RingN", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "setModifierInput RingHost", + "ok": true, + "detail": "Modifier input Count set." + }, + { + "name": "setModifierInput changes evaluated instances", + "ok": true, + "detail": [ + 8, + 12 + ] + }, + { + "name": "custom property naming a modifier input identifier is refused", + "ok": true, + "detail": "'Socket_1' is an input of Geometry Nodes modifier 'GN'; a custom property does not drive it, use setModifierInput." + }, + { + "name": "refused custom property changed nothing", + "ok": true, + "detail": null + }, + { + "name": "a custom property sharing only a display name is ordinary", + "ok": true, + "detail": "Blender custom property updated." + }, + { + "name": "createNodeGroup Boundary", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "addNode line", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode pos", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode len", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode gt", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode nrm", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode edge", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode set", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets Boundary", + "ok": true, + "detail": "Linked pos.Position -> len.Vector." + }, + { + "name": "linkSockets Boundary", + "ok": true, + "detail": "Linked len.Value -> gt.A." + }, + { + "name": "linkSockets Boundary", + "ok": true, + "detail": "Linked pos.Position -> nrm.Vector." + }, + { + "name": "linkSockets Boundary", + "ok": true, + "detail": "Linked nrm.Vector -> edge.Vector." + }, + { + "name": "linkSockets Boundary", + "ok": true, + "detail": "Linked line.Mesh -> set.Geometry." + }, + { + "name": "linkSockets Boundary", + "ok": true, + "detail": "Linked gt.Result -> set.Selection." + }, + { + "name": "linkSockets Boundary", + "ok": true, + "detail": "Linked edge.Vector -> set.Position." + }, + { + "name": "linkSockets Boundary", + "ok": true, + "detail": "Linked set.Geometry -> gout.Socket_0." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier Boundary", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "boundary_step vertices equal native", + "ok": true, + "detail": { + "brokkr": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.5, + 0.0, + 0.0 + ], + [ + 0.5, + 0.0, + 0.0 + ] + ], + "native": [ + [ + 0.0, + 0.0, + 0.0 + ], + [ + 0.5, + 0.0, + 0.0 + ], + [ + 0.5, + 0.0, + 0.0 + ] + ] + } + }, + { + "name": "createNodeGroup Scatter", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0, input Seed=Socket_1" + }, + { + "name": "addNode grid", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode dist", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode cone", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode iop", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets Scatter", + "ok": true, + "detail": "Linked grid.Mesh -> dist.Mesh." + }, + { + "name": "linkSockets Scatter", + "ok": true, + "detail": "Linked dist.Points -> iop.Points." + }, + { + "name": "linkSockets Scatter", + "ok": true, + "detail": "Linked cone.Mesh -> iop.Instance." + }, + { + "name": "linkSockets Scatter", + "ok": true, + "detail": "Linked iop.Instances -> gout.Socket_0." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier Scatter", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "scatter instances equal native", + "ok": true, + "detail": { + "brokkr": 38, + "native": 38 + } + }, + { + "name": "setModifierInput ScatterHost", + "ok": true, + "detail": "Modifier input Seed set." + }, + { + "name": "unwired exposed input leaves result", + "ok": true, + "detail": null + }, + { + "name": "unknown input refused", + "ok": true, + "detail": "input 'Nope': socket 'Nope' was not found." + }, + { + "name": "refused addNode left no node", + "ok": true, + "detail": null + }, + { + "name": "unknown property refused", + "ok": true, + "detail": "GeometryNodeMeshCube has no settable property 'nope'." + }, + { + "name": "file-reading node refused", + "ok": true, + "detail": "node type GeometryNodeImportOBJ is not allowed in this cut (Import)." + }, + { + "name": "unknown node type refused", + "ok": true, + "detail": "Blender does not offer node type GeometryNodeNoSuchThing: Error: Node type GeometryNodeNoSuchThing undefined\n" + }, + { + "name": "invalid link refused and removed", + "ok": true, + "detail": "Link Mesh -> Density is not valid (type or field/single-value mismatch)." + }, + { + "name": "wrong value type refused", + "ok": true, + "detail": "input 'Seed' must be a finite number within +-1e+09." + }, + { + "name": "duplicate id refused", + "ok": true, + "detail": "Node id 'grid' already exists in 'Scatter'." + }, + { + "name": "createNodeGroup L", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "addNode cube", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode m", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode iop", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets L", + "ok": true, + "detail": "Linked cube.Mesh -> iop.Points." + }, + { + "name": "refused link keeps the link it would have replaced", + "ok": true, + "detail": [ + "Link Value -> Points is not valid (type or field/single-value mismatch).", + [ + [ + "cube", + "Points" + ] + ], + [ + [ + "cube", + "Points" + ] + ] + ] + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "modifier on a camera refused clearly", + "ok": true, + "detail": "'Cam' is a CAMERA object; it cannot take a Geometry Nodes modifier." + }, + { + "name": "trailing newline in an id refused", + "ok": true, + "detail": "id must be 1..63 characters of letters, digits, space, '.', '_' or '-'." + }, + { + "name": "createNodeGroup Off", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "addNode circle", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode cube", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode iop", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode join", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets Off", + "ok": true, + "detail": "Linked circle.Mesh -> iop.Points." + }, + { + "name": "linkSockets Off", + "ok": true, + "detail": "Linked cube.Mesh -> iop.Instance." + }, + { + "name": "linkSockets Off", + "ok": true, + "detail": "Linked iop.Instances -> join.Geometry." + }, + { + "name": "linkSockets Off", + "ok": true, + "detail": "Linked circle.Mesh -> join.Geometry." + }, + { + "name": "linkSockets Off", + "ok": true, + "detail": "Linked join.Geometry -> gout.Socket_0." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier Off", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "local vertex bounds and world instance bounds are separate", + "ok": true, + "detail": { + "vertexCount": 4, + "edgeCount": 4, + "polygonCount": 0, + "instanceCount": 4, + "instancesComplete": true, + "positionsSkipped": false, + "nonFinite": false, + "vertexBoundsMin": [ + -1.0, + -1.0, + 0.0 + ], + "vertexBoundsMax": [ + 1.0, + 1.0, + 0.0 + ], + "vertexLocations": [ + [ + 1.0, + 0.0, + 0.0 + ], + [ + -0.0, + 1.0, + 0.0 + ], + [ + -1.0, + -0.0, + 0.0 + ], + [ + 0.0, + -1.0, + 0.0 + ] + ], + "instanceBoundsMin": [ + 99.0, + -1.0, + 0.0 + ], + "instanceBoundsMax": [ + 101.0, + 1.0, + 0.0 + ], + "instanceLocations": [ + [ + 101.0, + 0.0, + 0.0 + ], + [ + 100.0, + 1.0, + 0.0 + ], + [ + 99.0, + -0.0, + 0.0 + ], + [ + 100.0, + -1.0, + 0.0 + ] + ], + "truncated": false + } + }, + { + "name": "createNodeGroup Inf", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "addNode s0", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode s1", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode s2", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode s3", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode s4", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode s5", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode cube", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode set", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets Inf", + "ok": true, + "detail": "Linked s0.Vector -> s1.Vector." + }, + { + "name": "linkSockets Inf", + "ok": true, + "detail": "Linked s1.Vector -> s2.Vector." + }, + { + "name": "linkSockets Inf", + "ok": true, + "detail": "Linked s2.Vector -> s3.Vector." + }, + { + "name": "linkSockets Inf", + "ok": true, + "detail": "Linked s3.Vector -> s4.Vector." + }, + { + "name": "linkSockets Inf", + "ok": true, + "detail": "Linked s4.Vector -> s5.Vector." + }, + { + "name": "linkSockets Inf", + "ok": true, + "detail": "Linked cube.Mesh -> set.Geometry." + }, + { + "name": "linkSockets Inf", + "ok": true, + "detail": "Linked s5.Vector -> set.Position." + }, + { + "name": "linkSockets Inf", + "ok": true, + "detail": "Linked set.Geometry -> gout.Socket_0." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier Inf", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "non-finite evaluated positions are flagged and not published", + "ok": true, + "detail": { + "vertexCount": 8, + "edgeCount": 12, + "polygonCount": 6, + "instanceCount": 0, + "instancesComplete": true, + "positionsSkipped": false, + "nonFinite": true, + "vertexBoundsMin": [], + "vertexBoundsMax": [], + "vertexLocations": [], + "instanceBoundsMin": [], + "instanceBoundsMax": [], + "instanceLocations": [], + "truncated": false + } + }, + { + "name": "createNodeGroup R", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0, input Count=Socket_1" + }, + { + "name": "addNode circle", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode cube", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode iop", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode m1", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode m2", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets R", + "ok": true, + "detail": "Linked gin.Socket_1 -> circle.Vertices." + }, + { + "name": "linkSockets R", + "ok": true, + "detail": "Linked circle.Mesh -> iop.Points." + }, + { + "name": "linkSockets R", + "ok": true, + "detail": "Linked cube.Mesh -> iop.Instance." + }, + { + "name": "linkSockets R", + "ok": true, + "detail": "Linked iop.Instances -> gout.Socket_0." + }, + { + "name": "linkSockets R", + "ok": true, + "detail": "Linked m1.Value -> m2.Value." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier R", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "graph evaluates before removal", + "ok": true, + "detail": null + }, + { + "name": "cycle m2->m1 refused", + "ok": true, + "detail": "Link m2 -> m1 would make a cycle." + }, + { + "name": "cycle m1->m1 refused", + "ok": true, + "detail": "Link m1 -> m1 would make a cycle." + }, + { + "name": "refused cycle left the link set alone", + "ok": true, + "detail": null + }, + { + "name": "unlinkSockets R", + "ok": true, + "detail": "Removed 1 link(s)." + }, + { + "name": "unlinked exactly one link", + "ok": true, + "detail": "Removed 1 link(s)." + }, + { + "name": "unlinkSockets R", + "ok": true, + "detail": "Removed 0 link(s)." + }, + { + "name": "unlink of nothing is accepted", + "ok": true, + "detail": "Removed 0 link(s)." + }, + { + "name": "unlinkSockets R", + "ok": true, + "detail": "Removed 1 link(s)." + }, + { + "name": "unlinking the instance changes the result", + "ok": true, + "detail": { + "vertexCount": 0, + "edgeCount": 0, + "polygonCount": 0, + "instanceCount": 0, + "instancesComplete": true, + "positionsSkipped": false, + "nonFinite": false, + "vertexBoundsMin": [], + "vertexBoundsMax": [], + "vertexLocations": [], + "instanceBoundsMin": [], + "instanceBoundsMax": [], + "instanceLocations": [], + "truncated": false + } + }, + { + "name": "linkSockets R", + "ok": true, + "detail": "Linked cube.Mesh -> iop.Instance." + }, + { + "name": "relinked", + "ok": true, + "detail": null + }, + { + "name": "removeNode cube", + "ok": true, + "detail": "Node removed." + }, + { + "name": "removeNode drops the node and its links", + "ok": true, + "detail": null + }, + { + "name": "group output node stays", + "ok": true, + "detail": "'gout' is the group's own input/output node and stays." + }, + { + "name": "removeNode of an absent id is accepted", + "ok": true, + "detail": null + }, + { + "name": "removeModifier RHost", + "ok": true, + "detail": "Modifier removed." + }, + { + "name": "removeModifier removes it; evaluated block gone", + "ok": true, + "detail": null + }, + { + "name": "non-Geometry-Nodes modifier is not removed", + "ok": true, + "detail": "'Sub' is not a Geometry Nodes modifier; only those are removed here." + }, + { + "name": "createNodeGroup C", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "every closing link on a 60-node chain with skip links is refused", + "ok": true, + "detail": [ + 59, + 59, + 0.008 + ] + }, + { + "name": "createNodeGroup Mu", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "addNode cube", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode m", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode iop", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets Mu", + "ok": true, + "detail": "Linked cube.Mesh -> iop.Points." + }, + { + "name": "refused link restores the displaced link's muted state", + "ok": true, + "detail": [ + "Link Value -> Points is not valid (type or field/single-value mismatch).", + [ + true + ] + ] + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "hand-made Unicode modifier name is removable", + "ok": true, + "detail": "Modifier removed." + }, + { + "name": "createNodeGroup Heavy", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "addNode grid", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode cube", + "ok": true, + "detail": "Node added." + }, + { + "name": "addNode iop", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets Heavy", + "ok": true, + "detail": "Linked grid.Mesh -> iop.Points." + }, + { + "name": "linkSockets Heavy", + "ok": true, + "detail": "Linked cube.Mesh -> iop.Instance." + }, + { + "name": "linkSockets Heavy", + "ok": true, + "detail": "Linked iop.Instances -> gout.Socket_0." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier Heavy", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "2000x2000 instanced grid: capture is bounded", + "ok": true, + "detail": [ + 1.2, + 99999, + false + ] + }, + { + "name": "createNodeGroup Heavy", + "ok": true, + "detail": "Created node group; interface identifiers: output Geometry=Socket_0" + }, + { + "name": "addNode grid", + "ok": true, + "detail": "Node added." + }, + { + "name": "linkSockets Heavy", + "ok": true, + "detail": "Linked grid.Mesh -> gout.Socket_0." + }, + { + "name": "createObject ", + "ok": true, + "detail": "Blender object created." + }, + { + "name": "addModifier Heavy", + "ok": true, + "detail": "Geometry Nodes modifier set." + }, + { + "name": "2000x2000 mesh grid: counts only, bounded", + "ok": true, + "detail": [ + 0.05, + 4000000 + ] + } + ], + "cycle_probe_seconds": 0.008, + "capture_2000_grid_instanced_seconds": 1.198, + "capture_2000_grid_mesh_seconds": 0.045 +} \ No newline at end of file diff --git a/surfaces/blender/tests/live/gn_recreation.py b/surfaces/blender/tests/live/gn_recreation.py new file mode 100644 index 0000000..6ef8036 --- /dev/null +++ b/surfaces/blender/tests/live/gn_recreation.py @@ -0,0 +1,178 @@ +"""Recreate geometry-node setups through the Brokkr scene/object actions and compare with the native result. + + BROKKR_ADDON= \ + 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) diff --git a/surfaces/blender/tests/live/gn_recreation.result.json b/surfaces/blender/tests/live/gn_recreation.result.json new file mode 100644 index 0000000..24b4332 --- /dev/null +++ b/surfaces/blender/tests/live/gn_recreation.result.json @@ -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" + } + } +} \ No newline at end of file diff --git a/surfaces/blender/tests/test_node_actions.py b/surfaces/blender/tests/test_node_actions.py new file mode 100644 index 0000000..4c333f1 --- /dev/null +++ b/surfaces/blender/tests/test_node_actions.py @@ -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()