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:
parent
22401eab0b
commit
a318851ed2
8 changed files with 494 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [],
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue