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 2154b80..dcb1add 100644 --- a/surfaces/blender/brokkr_bridge/blender_target.py +++ b/surfaces/blender/brokkr_bridge/blender_target.py @@ -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 [], diff --git a/surfaces/blender/brokkr_bridge/node_actions.py b/surfaces/blender/brokkr_bridge/node_actions.py index cfce6a5..844a6d8 100644 --- a/surfaces/blender/brokkr_bridge/node_actions.py +++ b/surfaces/blender/brokkr_bridge/node_actions.py @@ -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"] diff --git a/surfaces/blender/docs/node-actions.md b/surfaces/blender/docs/node-actions.md index d0b7d56..b9d5928 100644 --- a/surfaces/blender/docs/node-actions.md +++ b/surfaces/blender/docs/node-actions.md @@ -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`. diff --git a/surfaces/blender/tests/live/gn_actions.py b/surfaces/blender/tests/live/gn_actions.py index 368f53a..a3a4016 100644 --- a/surfaces/blender/tests/live/gn_actions.py +++ b/surfaces/blender/tests/live/gn_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") diff --git a/surfaces/blender/tests/live/gn_actions.result.json b/surfaces/blender/tests/live/gn_actions.result.json index 59a5f0b..460dcbf 100644 --- a/surfaces/blender/tests/live/gn_actions.result.json +++ b/surfaces/blender/tests/live/gn_actions.result.json @@ -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 } \ No newline at end of file diff --git a/surfaces/blender/tests/test_node_actions.py b/surfaces/blender/tests/test_node_actions.py index 2a26ea2..2597eb7 100644 --- a/surfaces/blender/tests/test_node_actions.py +++ b/surfaces/blender/tests/test_node_actions.py @@ -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__":