Blender node actions: removeNode, unlinkSockets, removeModifier, cycle refusal, bounded instance scan

Cycles and self links are refused before Blender is asked; the snapshot's instance scan stops at
100k depsgraph items and says instancesComplete=false (2000x2000 instanced grid: 22 s -> 1.4 s);
the daemon advertisement lists the scene-action and node-graph capabilities. Live: 156 checks pass
on Blender 5.2.3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-10 06:35:58 +00:00
commit a318851ed2
8 changed files with 494 additions and 14 deletions

View file

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

View file

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

View file

@ -36,6 +36,7 @@ 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",
@ -817,7 +818,13 @@ class BrokkrBlenderTarget:
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})
@ -827,7 +834,7 @@ class BrokkrBlenderTarget:
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
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
@ -836,7 +843,7 @@ class BrokkrBlenderTarget:
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 = scan
depsgraph, found, complete = scan
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh()
try:
@ -853,6 +860,7 @@ class BrokkrBlenderTarget:
points = [coords[i:i + 3] for i in range(0, 3 * count, 3)] if coords is not None else []
result.update({
"instanceCount": entry["count"],
"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 [],

View file

@ -116,7 +116,7 @@ def check_interface(items: Any) -> list[dict[str, str]]:
class NodeActions:
"""Applies the node-graph actions to a bpy module. Stateless: every lookup goes to bpy.data."""
ACTIONS = ("createNodeGroup", "addNode", "linkSockets", "addModifier", "setModifierInput")
ACTIONS = ("createNodeGroup", "addNode", "removeNode", "linkSockets", "unlinkSockets", "addModifier", "removeModifier", "setModifierInput")
def __init__(self, bpy_module: Any):
self.bpy = bpy_module
@ -228,12 +228,38 @@ class NodeActions:
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 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:
@ -270,6 +296,18 @@ class NodeActions:
obj.update_tag()
return "Geometry Nodes modifier set.", obj.name
def _removeModifier(self, command: dict[str, Any]) -> tuple[str, str]:
obj = self._object(command)
name = str(command.get("modifierName") or "Brokkr GeoNodes")
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"))
@ -283,6 +321,21 @@ class NodeActions:
return f"Modifier input {item.name} set.", obj.name
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)."""
seen: set[int] = set()
stack = [start]
while stack:
node = stack.pop()
if node == goal:
return True
if id(node) in seen:
continue
seen.add(id(node))
stack.extend(l.to_node for l in tree.links if l.from_node == node)
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"]

View file

@ -9,8 +9,11 @@ Design and the parts not built yet: `docs/blender-node-graphs.md` (on `libby/ble
|---|---|---|
| `createNodeGroup` | `treeKey`, `interface` `[{inOut, name, type, default?}]` | New geometry node group with `gin` and `gout` nodes. Types: Geometry, Float, Int, Bool, Vector. Refuses an existing group. The receipt lists the interface identifiers (`Socket_N`). |
| `addNode` | `treeKey`, `id`, `blIdname`, `location?`, `label?`, `properties?`, `inputs?` | `id` is Brokkr's key (stored as the node's `brokkr_id`). Type must be a `GeometryNode*`, `FunctionNode*` or `ShaderNode*` name that is not an importer, exporter, script, bake, group, zone, viewer or image-texture node. Properties are enum/bool/int/float node settings (set first, they decide which sockets exist); inputs are unlinked defaults (float, int, bool, vector, colour). At most 500 nodes per group. |
| `linkSockets` | `treeKey`, `fromNode`, `fromSocket`, `toNode`, `toSocket` | Sockets by identifier, or by display name when exactly one available socket has it (Group Input/Output sockets are addressed by interface name). A link Blender calls invalid (field into a single value, type mismatch) is removed and refused, and the single link it displaced is put back. A valid link into a single-link input replaces the old one (Blender's behaviour); cycles and self links are not detected here. |
| `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
@ -22,13 +25,13 @@ polygon counts, `instanceCount`, and up to 256 vertex and instance locations (`t
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.
`instanceCount` counts evaluated instances whose parent is the object, so a curve, point or volume result
`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` 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.
Not built: zones, item sockets, multi-input ordering, removal and unlink actions, library groups,
group-in-group nodes, batches/rollback across intents, an expectedRevision check. Int values are capped at +-4096 and floats at +-1e9, which bounds a request but not the cost of a graph
(a 4096 x 4096 grid is 16M vertices): Blender evaluates what it is given, and the snapshot evaluates every mesh
with modifiers on each capture. There is no removal or unlink action yet, so a heavy graph stays until the
user removes it in Blender. `addModifier` refuses object types that take no modifiers.
Not built: zones, item sockets, multi-input ordering, library groups, group-in-group nodes, batches/rollback across
intents, an expectedRevision check, per-node cost estimates. Int values are capped at +-4096 and floats at +-1e9,
which bounds a request but not the cost of a graph: Blender evaluates what it is given, and the snapshot evaluates
every mesh with modifiers on each capture (mesh grids measured: 16.7M vertices evaluate and read in 0.4 s).
`addModifier` refuses object types that take no modifiers.
Live proof: `tests/live/gn_actions.py` (Blender 5.2.3, headless). Unit tests: `tests/test_node_actions.py`.

View file

@ -185,6 +185,56 @@ ok(t3, action="createObject", objectType="MESH", name="InfHost"); ok(t3, action=
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"])
# ---- 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")

View file

@ -584,6 +584,7 @@
"edgeCount": 4,
"polygonCount": 0,
"instanceCount": 4,
"instancesComplete": true,
"positionsSkipped": false,
"nonFinite": false,
"vertexBoundsMin": [
@ -756,6 +757,7 @@
"edgeCount": 12,
"polygonCount": 6,
"instanceCount": 0,
"instancesComplete": true,
"positionsSkipped": false,
"nonFinite": true,
"vertexBoundsMin": [],
@ -766,6 +768,270 @@
"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 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.38,
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
]
}
]
],
"capture_2000_grid_instanced_seconds": 1.375,
"capture_2000_grid_mesh_seconds": 0.051
}

View file

@ -40,6 +40,8 @@ class Node(dict):
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"):
@ -67,7 +69,7 @@ class Links(list):
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, is_valid=self.valid(a, b) if callable(self.valid) else self.valid)
link = NS(from_socket=a, to_socket=b, from_node=a.node, to_node=b.node, is_valid=self.valid(a, b) if callable(self.valid) else self.valid)
self.append(link)
return link
@ -116,6 +118,9 @@ class Modifiers(list):
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
@ -250,8 +255,9 @@ class NodeActionTests(unittest.TestCase):
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="a", toSocket="Dup")
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()
@ -307,6 +313,62 @@ class NodeActionTests(unittest.TestCase):
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_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")
# setObjectCustomProperty on a Geometry Nodes host
def test_custom_property_naming_a_modifier_input_identifier_is_refused(self):
self.group()
@ -419,9 +481,26 @@ class NodeActionTests(unittest.TestCase):
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):
data = self.evaluated(host)
self.assertEqual((data["instanceCount"], data["instancesComplete"]), (10, False))
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, {})))
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], (None, {}, True)))
if __name__ == "__main__":