Compare commits

..
Author SHA1 Message Date
Libby
177b4b1fcf Blender add-on tests: the guard test no longer pins the store's spelling of an empty put
CultLib's store now refuses it as message_invalid, not malformed, so the test failed on
main against current CultLib. It now asserts what it means: past the guard, a coded
store refusal that is not the guard's own (codeless, names the token field).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:42:29 +00:00
15 changed files with 13 additions and 3508 deletions

View file

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

View file

@ -220,9 +220,6 @@ All Blender writes use `brokkr.blender.command_intent.v0` and receive
- `setLight` - `setLight`
- `setWorld` - `setWorld`
- `renderStill` - `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 Blender owns the mutation. Brokkr advertises the command surface; Verse clients
write typed command intents; Blender executes recognized intents and publishes write typed command intents; Blender executes recognized intents and publishes

View file

@ -1,18 +1,14 @@
from __future__ import annotations from __future__ import annotations
import ctypes
import functools
import json import json
import math import math
import os import os
import sys import sys
import uuid import uuid
from array import array
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from . import node_actions as na
from . import scene_actions as sa from . import scene_actions as sa
from .server_guard import BindRefused, SessionGuard, check_bind, guarded_server_class, is_loopback_host, purge_command_intents from .server_guard import BindRefused, SessionGuard, check_bind, guarded_server_class, is_loopback_host, purge_command_intents
@ -36,17 +32,6 @@ SYNC_VAR_SCHEMA = "brokkr.sync.var.v0"
SYNC_TIMELINE_BINDING_SCHEMA = "brokkr.sync.timeline_binding.v0" SYNC_TIMELINE_BINDING_SCHEMA = "brokkr.sync.timeline_binding.v0"
SYNC_RECEIPT_SCHEMA = "brokkr.sync.receipt.v0" SYNC_RECEIPT_SCHEMA = "brokkr.sync.receipt.v0"
CREATABLE_OBJECT_TYPES = ("MESH", "LIGHT", "CAMERA", "EMPTY")
# Engines that draw through the GL stack. A headless Linux Blender without libEGL aborts the whole
# process inside bpy.ops.render.render for these (no receipt); CYCLES renders on the CPU.
GL_RENDER_ENGINES = ("BLENDER_WORKBENCH", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT")
GL_RUNTIME_LIBRARY = "libEGL.so.1"
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 = ( CAPABILITIES = (
"cultcache.mirror.publish", "cultcache.mirror.publish",
"cultcache.intent.watch", "cultcache.intent.watch",
@ -61,13 +46,10 @@ CAPABILITIES = (
"selection.read", "selection.read",
"selection.write", "selection.write",
"asset.import.gltf", "asset.import.gltf",
"asset.export.gltf",
"camera.write", "camera.write",
"light.write", "light.write",
"world.write", "world.write",
"render.still", "render.still",
"nodegraph.geometry.write",
"geometry.evaluated.read",
"command.receipt.publish", "command.receipt.publish",
) )
@ -91,7 +73,6 @@ class BrokkrBlenderTarget:
self._debug_mirror_root: str = "" self._debug_mirror_root: str = ""
self.import_dir: str = "" self.import_dir: str = ""
self.output_dir: str = "" self.output_dir: str = ""
self.nodes = na.NodeActions(bpy_module)
def configure_action_dirs(self, import_dir: str, output_dir: str) -> None: def configure_action_dirs(self, import_dir: str, output_dir: str) -> None:
"""The directories importGltf and renderStill may touch; empty leaves that action disabled.""" """The directories importGltf and renderStill may touch; empty leaves that action disabled."""
@ -460,7 +441,6 @@ class BrokkrBlenderTarget:
def capture_snapshot(self, context: Any) -> dict[str, Any]: def capture_snapshot(self, context: Any) -> dict[str, Any]:
bpy = self.bpy bpy = self.bpy
scan = self._instance_scan() if any(o.type == "MESH" and len(o.modifiers) for o in bpy.data.objects) else None
selected = [item.name for item in context.selected_objects if item is not None] selected = [item.name for item in context.selected_objects if item is not None]
active_object = context.active_object.name if context.active_object else "" active_object = context.active_object.name if context.active_object else ""
active_scene = context.scene.name if context.scene else "" active_scene = context.scene.name if context.scene else ""
@ -483,7 +463,7 @@ class BrokkrBlenderTarget:
"imageCount": len(bpy.data.images), "imageCount": len(bpy.data.images),
"capabilities": list(CAPABILITIES), "capabilities": list(CAPABILITIES),
"scenes": [self._scene_snapshot(scene) for scene in bpy.data.scenes], "scenes": [self._scene_snapshot(scene) for scene in bpy.data.scenes],
"objects": [self._object_snapshot(obj, scan) for obj in bpy.data.objects], "objects": [self._object_snapshot(obj) for obj in bpy.data.objects],
"materials": [self._material_snapshot(material) for material in bpy.data.materials], "materials": [self._material_snapshot(material) for material in bpy.data.materials],
"collections": [self._collection_snapshot(collection) for collection in bpy.data.collections], "collections": [self._collection_snapshot(collection) for collection in bpy.data.collections],
} }
@ -509,8 +489,6 @@ class BrokkrBlenderTarget:
return self._assign_material(context, command) return self._assign_material(context, command)
if action == "importGltf": if action == "importGltf":
return self._import_gltf(context, command) return self._import_gltf(context, command)
if action == "exportGltf":
return self._export_gltf(context, command)
if action == "setCamera": if action == "setCamera":
return self._set_camera(context, command) return self._set_camera(context, command)
if action == "setLight": if action == "setLight":
@ -519,9 +497,6 @@ class BrokkrBlenderTarget:
return self._set_world(context, command) return self._set_world(context, command)
if action == "renderStill": if action == "renderStill":
return self._render_still(context, command) return self._render_still(context, command)
if action in na.NodeActions.ACTIONS:
message, name = self.nodes.execute(action, command)
return self._receipt(command, "accepted", message, name)
return self._receipt(command, "failed", f"Unsupported Blender command action: {action}", "") return self._receipt(command, "failed", f"Unsupported Blender command action: {action}", "")
except sa.ActionRefused as error: except sa.ActionRefused as error:
return self._receipt(command, "failed", str(error), "") return self._receipt(command, "failed", str(error), "")
@ -530,14 +505,6 @@ class BrokkrBlenderTarget:
def _create_object(self, context: Any, command: dict[str, Any]) -> dict[str, Any]: def _create_object(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
object_type = command.get("objectType", "EMPTY") object_type = command.get("objectType", "EMPTY")
if not isinstance(object_type, str) or object_type not in CREATABLE_OBJECT_TYPES:
shown = object_type if isinstance(object_type, str) else repr(object_type)
raise sa.ActionRefused(
f"objectType {shown!r} cannot be created; allowed: {', '.join(CREATABLE_OBJECT_TYPES)}.")
mesh_type = command.get("meshType")
if object_type == "MESH" and mesh_type is not None and (
not isinstance(mesh_type, str) or mesh_type.upper() != "CUBE"):
raise sa.ActionRefused(f"meshType {mesh_type!r} is not available; a MESH is a CUBE.")
name = command.get("name") or f"Brokkr {object_type.title()}" name = command.get("name") or f"Brokkr {object_type.title()}"
location = _vector(command.get("location"), 3, (0.0, 0.0, 0.0)) location = _vector(command.get("location"), 3, (0.0, 0.0, 0.0))
@ -547,7 +514,7 @@ class BrokkrBlenderTarget:
self.bpy.ops.object.light_add(type=command.get("lightType", "POINT"), location=location) self.bpy.ops.object.light_add(type=command.get("lightType", "POINT"), location=location)
elif object_type == "CAMERA": elif object_type == "CAMERA":
self.bpy.ops.object.camera_add(location=location) self.bpy.ops.object.camera_add(location=location)
else: # EMPTY else:
self.bpy.ops.object.empty_add(type=command.get("emptyDisplayType", "PLAIN_AXES"), location=location) self.bpy.ops.object.empty_add(type=command.get("emptyDisplayType", "PLAIN_AXES"), location=location)
obj = context.object obj = context.object
@ -606,10 +573,6 @@ class BrokkrBlenderTarget:
property_name = command.get("propertyName") or "brokkrValue" property_name = command.get("propertyName") or "brokkrValue"
if property_name.startswith("customProperties."): if property_name.startswith("customProperties."):
property_name = property_name[len("customProperties."):] property_name = property_name[len("customProperties."):]
modifier_name = na.drives_modifier_input(obj, property_name)
if modifier_name:
return self._receipt(command, "failed", f"{property_name!r} is an input of Geometry Nodes modifier {modifier_name!r}; "
"a custom property does not drive it, use setModifierInput.", obj.name)
obj[property_name] = command.get("value") obj[property_name] = command.get("value")
return self._receipt(command, "accepted", "Blender custom property updated.", obj.name) return self._receipt(command, "accepted", "Blender custom property updated.", obj.name)
@ -630,22 +593,12 @@ class BrokkrBlenderTarget:
if obj is None: if obj is None:
return self._receipt(command, "failed", "Target Blender object was not found.", "") return self._receipt(command, "failed", "Target Blender object was not found.", "")
if not (obj.data and hasattr(obj.data, "materials")):
return self._receipt(command, "failed", "Target object cannot receive materials.", obj.name)
rgba = sa.color_rgba(command) # refused before anything is created or changed
material_name = command.get("materialName") or "Brokkr Material" material_name = command.get("materialName") or "Brokkr Material"
material = self.bpy.data.materials.get(material_name) or self.bpy.data.materials.new(material_name) material = self.bpy.data.materials.get(material_name) or self.bpy.data.materials.new(material_name)
if rgba is not None: if obj.data and hasattr(obj.data, "materials"):
if not material.use_nodes or material.node_tree is None:
material.use_nodes = True
nodes = material.node_tree.nodes
principled = nodes.get("Principled BSDF") or next(
(n for n in nodes if n.bl_idname == "ShaderNodeBsdfPrincipled"), None) or nodes.new("ShaderNodeBsdfPrincipled")
principled.inputs["Base Color"].default_value = rgba
material.diffuse_color = rgba
if not any(slot is not None and slot.name == material.name for slot in obj.data.materials):
obj.data.materials.append(material) obj.data.materials.append(material)
return self._receipt(command, "accepted", "Blender material assigned.", obj.name) return self._receipt(command, "accepted", "Blender material assigned.", obj.name)
return self._receipt(command, "failed", "Target object cannot receive materials.", obj.name)
def _import_gltf(self, context: Any, command: dict[str, Any]) -> dict[str, Any]: def _import_gltf(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
name = str(command.get("filepath") or "") name = str(command.get("filepath") or "")
@ -666,61 +619,6 @@ class BrokkrBlenderTarget:
added = sorted(set(self.bpy.data.objects.keys()) - before) added = sorted(set(self.bpy.data.objects.keys()) - before)
return self._receipt(command, "accepted", f"Imported {len(added)} object(s) from {path.name}.", added[0] if added else "") return self._receipt(command, "accepted", f"Imported {len(added)} object(s) from {path.name}.", added[0] if added else "")
def _export_gltf(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
"""Write the scene (or the named objects) as .glb or .gltf under the output directory."""
name = command.get("filepath")
if not isinstance(name, str):
raise sa.ActionRefused("filepath must be a string.")
path = sa.resolve_inside(self.output_dir, name, "output", new_file=True)
suffix = path.suffix.lower()
if suffix not in sa.GLTF_EXTENSIONS:
raise sa.ActionRefused(f"Export path must end in {' or '.join(sa.GLTF_EXTENSIONS)}.")
options = {}
for field, key in (("extras", "export_extras"), ("yUp", "export_yup"), ("applyModifiers", "export_apply")):
value = command.get(field, True)
if not isinstance(value, bool):
raise sa.ActionRefused(f"{field} must be true or false.")
options[key] = value
names = command.get("selection")
chosen = []
if names is not None:
if not isinstance(names, list) or not names or not all(isinstance(n, str) and n for n in names):
raise sa.ActionRefused("selection must be a non-empty list of object names.")
missing = [n for n in names if n not in self.bpy.data.objects]
if missing:
raise sa.ActionRefused(f"Selection object was not found: {missing[0]}")
chosen = [self.bpy.data.objects[n] for n in dict.fromkeys(names)]
# select_set raises for an object with no base in the view layer (another scene, an
# orphan, an excluded collection), and use_selection exports only selected layer objects.
off_layer = [o.name for o in chosen if o.name not in context.view_layer.objects]
if off_layer:
raise sa.ActionRefused(f"Selection object is not in the active view layer: {off_layer[0]}")
binary = path.with_suffix(".bin")
if suffix == ".gltf" and os.path.lexists(binary):
raise sa.ActionRefused("output path already exists; output actions never overwrite: " + binary.name)
path.parent.mkdir(parents=True, exist_ok=True)
# Re-check after the folders exist and just before use (residual TOCTOU as in renderStill).
if sa.resolve_inside(self.output_dir, name, "output", new_file=True) != path:
raise sa.ActionRefused("Output path changed while it was being checked.")
# The selection is touched only when one was asked for, and only on view-layer objects.
objects = list(context.view_layer.objects.values()) if chosen else []
saved = [(obj, obj.select_get()) for obj in objects]
try:
for obj in objects:
obj.select_set(obj in chosen)
result = self.bpy.ops.export_scene.gltf(
filepath=str(path), export_format="GLB" if suffix == ".glb" else "GLTF_SEPARATE",
use_selection=bool(chosen), **options)
finally:
for obj, state in saved:
if obj.name in self.bpy.data.objects:
obj.select_set(state)
if "FINISHED" not in result:
raise sa.ActionRefused(f"glTF export did not finish: {sorted(result)}")
if not path.is_file():
raise sa.ActionRefused("glTF export wrote no file.")
return self._receipt(command, "accepted", f"Exported glTF to {path} ({path.stat().st_size} bytes).", "")
def _resolve_typed(self, command: dict[str, Any], object_type: str) -> Any: def _resolve_typed(self, command: dict[str, Any], object_type: str) -> Any:
obj = self._resolve_object(command) obj = self._resolve_object(command)
if obj is None: if obj is None:
@ -826,12 +724,6 @@ class BrokkrBlenderTarget:
render.engine = engine render.engine = engine
except TypeError: except TypeError:
raise sa.ActionRefused(f"engine {engine} is not available in this Blender.") from None raise sa.ActionRefused(f"engine {engine} is not available in this Blender.") from None
if render.engine in GL_RENDER_ENGINES and getattr(getattr(self.bpy, "app", None), "background", False):
missing = missing_gl_library()
if missing:
raise sa.ActionRefused(
f"engine {render.engine} needs {missing}, which this headless Blender cannot load "
"(rendering would abort the process); use engine CYCLES.")
if width is not None: if width is not None:
render.resolution_x = width render.resolution_x = width
if height is not None: if height is not None:
@ -869,7 +761,7 @@ class BrokkrBlenderTarget:
"collectionName": scene.collection.name if scene.collection else "", "collectionName": scene.collection.name if scene.collection else "",
} }
def _object_snapshot(self, obj: Any, scan: Any = None) -> dict[str, Any]: def _object_snapshot(self, obj: Any) -> dict[str, Any]:
data = obj.data if hasattr(obj, "data") else None data = obj.data if hasattr(obj, "data") else None
return { return {
"name": obj.name, "name": obj.name,
@ -888,7 +780,6 @@ class BrokkrBlenderTarget:
"constraints": [constraint.name for constraint in obj.constraints], "constraints": [constraint.name for constraint in obj.constraints],
"camera": self._camera_snapshot(data) if obj.type == "CAMERA" and data else None, "camera": self._camera_snapshot(data) if obj.type == "CAMERA" and data else None,
"mesh": self._mesh_snapshot(data) if obj.type == "MESH" and data else None, "mesh": self._mesh_snapshot(data) if obj.type == "MESH" and data else None,
"evaluated": self._evaluated_snapshot(obj, scan),
"customProperties": self._custom_properties(obj.items()), "customProperties": self._custom_properties(obj.items()),
} }
@ -910,70 +801,6 @@ class BrokkrBlenderTarget:
"shapeKeyCount": len(mesh.shape_keys.key_blocks) if mesh.shape_keys else 0, "shapeKeyCount": len(mesh.shape_keys.key_blocks) if mesh.shape_keys else 0,
} }
def _instance_scan(self) -> Any:
"""One pass over the depsgraph's instances: per original parent object, the count, the first locations
and the running bounds (world space). Nothing is kept per instance beyond the cap."""
depsgraph = self.bpy.context.evaluated_depsgraph_get()
depsgraph.update()
found: dict[str, dict[str, Any]] = {}
visited = 0
complete = True
for item in depsgraph.object_instances:
visited += 1
if visited > EVALUATED_INSTANCE_SCAN_LIMIT:
complete = False
break
if not item.is_instance or item.parent is None:
continue
entry = found.setdefault(item.parent.original.name, {"count": 0, "first": [], "min": None, "max": None})
point = tuple(float(c) for c in item.matrix_world.translation)
entry["count"] += 1
if len(entry["first"]) < EVALUATED_POSITION_CAP:
entry["first"].append([round(c, 5) for c in point])
entry["min"] = list(point) if entry["min"] is None else [min(a, b) for a, b in zip(entry["min"], point)]
entry["max"] = list(point) if entry["max"] is None else [max(a, b) for a, b in zip(entry["max"], point)]
return depsgraph, found, complete
def _evaluated_snapshot(self, obj: Any, scan: Any) -> dict[str, Any] | None:
"""What the modifier stack produced, for mesh objects that have one. Vertex positions and their bounds
are in the object's local space; instance locations and their bounds are world space. A Geometry Nodes
object's own mesh is empty, so the base mesh counts say nothing about it. Above EVALUATED_VERTEX_LIMIT
vertices only the counts are read, and any non-finite coordinate drops the positions and sets nonFinite."""
if obj.type != "MESH" or not len(obj.modifiers) or scan is None:
return None
depsgraph, found, complete = scan
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh()
try:
count = len(mesh.vertices)
result = {"vertexCount": count, "edgeCount": len(mesh.edges), "polygonCount": len(mesh.polygons)}
coords = array("f", bytes(12 * count)) if 0 < count <= EVALUATED_VERTEX_LIMIT else None
if coords is not None:
mesh.vertices.foreach_get("co", coords)
finally:
evaluated.to_mesh_clear()
entry = found.get(obj.name)
reached = entry is not None
entry = entry or {"count": 0, "first": [], "min": [], "max": []}
finite = all(math.isfinite(c) for c in coords) if coords is not None else True
finite = finite and all(math.isfinite(c) for c in (entry["min"] or []) + (entry["max"] or []) + [c for p in entry["first"] for c in p])
points = [coords[i:i + 3] for i in range(0, 3 * count, 3)] if coords is not None else []
result.update({
# An object the stopped scan never reached has an unknown count, not zero.
"instanceCount": entry["count"] if complete or reached else None,
"instancesComplete": complete,
"positionsSkipped": count > EVALUATED_VERTEX_LIMIT,
"nonFinite": not finite,
"vertexBoundsMin": [min(p[a] for p in points) for a in range(3)] if points and finite else [],
"vertexBoundsMax": [max(p[a] for p in points) for a in range(3)] if points and finite else [],
"vertexLocations": [[round(c, 5) for c in p] for p in points[:EVALUATED_POSITION_CAP]] if finite else [],
"instanceBoundsMin": entry["min"] if finite and entry["min"] else [],
"instanceBoundsMax": entry["max"] if finite and entry["max"] else [],
"instanceLocations": entry["first"] if finite else [],
"truncated": count > EVALUATED_POSITION_CAP or entry["count"] > EVALUATED_POSITION_CAP,
})
return result
def _modifier_snapshot(self, modifier: Any) -> dict[str, Any]: def _modifier_snapshot(self, modifier: Any) -> dict[str, Any]:
return { return {
"name": modifier.name, "name": modifier.name,
@ -1114,19 +941,6 @@ def _load_cultmesh(cultlib_py_src: str) -> tuple[Any, Any]:
return cultmesh_py, cultcache_py return cultmesh_py, cultcache_py
@functools.lru_cache(maxsize=None)
def missing_gl_library() -> str | None:
"""The GL runtime library this host cannot load, or None. Probed once, on Linux only: on
Windows and macOS a failed GPU render is not a headless-EGL abort."""
if not sys.platform.startswith("linux"):
return None
try:
ctypes.CDLL(GL_RUNTIME_LIBRARY)
except OSError:
return GL_RUNTIME_LIBRARY
return None
def _now() -> str: def _now() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()

View file

@ -1,375 +0,0 @@
"""Geometry node graph command actions: createNodeGroup, addNode, linkSockets, addModifier, setModifierInput.
Owner of how an intent becomes a node group, a node, a link or a modifier input on Blender 5.2.
Validation is plain Python and runs before bpy is touched; apply steps that can still fail after
Blender has been asked (an unknown node type, an enum that is not offered) undo what they made, so a
refused intent leaves the tree as it was. Docs: docs/node-actions.md. The design is PR #3 (branch libby/blender-node-graphs-design).
"""
from __future__ import annotations
import math
import re
from typing import Any
from .scene_actions import ActionRefused
ID_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,63}")
KEY_PATTERN = re.compile(r"[A-Za-z0-9 ._-]{1,63}")
# A geometry tree may hold geometry, function and shader math nodes. Nodes that read or write files,
# run code, write caches, nest groups or sample images are not part of this cut.
NODE_IDNAME = re.compile(r"(GeometryNode|FunctionNode|ShaderNode)[A-Za-z0-9]+")
NODE_DENIED = ("Import", "Export", "Script", "Bake", "Group", "Simulation", "Repeat", "Foreach", "Viewer", "TexImage", "TexEnvironment",
"ImageTexture", "InputImage", "TexIES")
MAX_NODES = 500
MAX_INTERFACE = 64
MAX_MODIFIERS = 8
MAX_VALUE = 1.0e9
MAX_INT = 4096 # counts, levels and sizes multiply: a 4096 x 4096 grid is the ceiling
SOCKET_TYPES = {"Geometry": "NodeSocketGeometry", "Float": "NodeSocketFloat", "Int": "NodeSocketInt",
"Bool": "NodeSocketBool", "Vector": "NodeSocketVector"}
IN_OUT = {"INPUT", "OUTPUT"}
# Node properties that are not node settings, or that make a node reach outside the tree.
NODE_PROPERTY_DENIED = frozenset({"name", "label", "location", "width", "height", "parent", "node_tree", "select",
"mute", "hide", "show_options", "show_preview", "show_texture", "use_custom_color",
"color", "bl_idname", "bl_label", "bl_description", "bl_icon", "bl_static_type",
"bl_width_default", "bl_width_min", "bl_width_max", "bl_height_default",
"bl_height_min", "bl_height_max", "rna_type", "type", "internal_links"})
SETTABLE_PROPERTY_TYPES = ("ENUM", "BOOLEAN", "INT", "FLOAT")
def text(command: dict[str, Any], key: str, pattern: re.Pattern, label: str, *, required: bool = True) -> str:
value = command.get(key)
if value is None or value == "":
if required:
raise ActionRefused(f"{label} is required.")
return ""
if not isinstance(value, str) or not pattern.fullmatch(value):
raise ActionRefused(f"{label} must be 1..63 characters of letters, digits, space, '.', '_' or '-'.")
return value
def tree_key(command: dict[str, Any]) -> str:
kind = command.get("treeKind", "geometry")
if kind != "geometry":
raise ActionRefused(f"treeKind {kind!r} is not supported; only 'geometry'.")
return text(command, "treeKey", KEY_PATTERN, "treeKey")
def finite(value: Any, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or abs(value) > MAX_VALUE:
raise ActionRefused(f"{label} must be a finite number within +-{MAX_VALUE:g}.")
return float(value)
def typed_value(value: Any, kind: str, label: str) -> Any:
"""A value for a socket of `kind` (Blender's socket.type, or the interface type's tail), or ActionRefused."""
if kind in ("VALUE", "FLOAT", "Float"):
return finite(value, label)
if kind in ("INT", "Int"):
number = finite(value, label)
if number != int(number) or abs(number) > MAX_INT:
raise ActionRefused(f"{label} must be a whole number within +-{MAX_INT}.")
return int(number)
if kind in ("BOOLEAN", "Bool"):
if not isinstance(value, bool):
raise ActionRefused(f"{label} must be true or false.")
return value
if kind in ("VECTOR", "Vector", "RGBA"):
size = 4 if kind == "RGBA" else 3
if not isinstance(value, (list, tuple)) or len(value) != size:
raise ActionRefused(f"{label} must be exactly {size} numbers.")
return tuple(finite(part, f"{label}[{i}]") for i, part in enumerate(value))
raise ActionRefused(f"{label}: socket type {kind} cannot be set by an intent.")
def check_idname(idname: Any) -> str:
if not isinstance(idname, str) or not NODE_IDNAME.fullmatch(idname):
raise ActionRefused("blIdname must be a GeometryNode*, FunctionNode* or ShaderNode* type.")
for denied in NODE_DENIED:
if denied in idname:
raise ActionRefused(f"node type {idname} is not allowed in this cut ({denied}).")
return idname
def check_interface(items: Any) -> list[dict[str, str]]:
if not isinstance(items, list) or not items or len(items) > MAX_INTERFACE:
raise ActionRefused(f"interface must be a list of 1..{MAX_INTERFACE} sockets.")
seen: set[tuple[str, str]] = set()
result = []
for i, item in enumerate(items):
if not isinstance(item, dict):
raise ActionRefused(f"interface[{i}] must be an object.")
in_out = str(item.get("inOut", "")).upper()
name = item.get("name")
socket_type = item.get("type")
if in_out not in IN_OUT or not isinstance(name, str) or not name or len(name) > 63 or socket_type not in SOCKET_TYPES:
raise ActionRefused(f"interface[{i}] needs inOut INPUT|OUTPUT, a name and a type of {sorted(SOCKET_TYPES)}.")
if (in_out, name) in seen:
raise ActionRefused(f"interface[{i}] repeats {in_out} socket {name!r}.")
seen.add((in_out, name))
entry = {"inOut": in_out, "name": name, "type": socket_type}
if "default" in item:
entry["default"] = typed_value(item["default"], socket_type, f"interface[{i}].default")
result.append(entry)
return result
class NodeActions:
"""Applies the node-graph actions to a bpy module. Stateless: every lookup goes to bpy.data."""
ACTIONS = ("createNodeGroup", "addNode", "removeNode", "linkSockets", "unlinkSockets", "addModifier", "removeModifier", "setModifierInput")
def __init__(self, bpy_module: Any):
self.bpy = bpy_module
def execute(self, action: str, command: dict[str, Any]) -> tuple[str, str]:
return getattr(self, "_" + action)(command)
# tree and node lookup
def _tree(self, key: str) -> Any:
tree = self.bpy.data.node_groups.get(key)
if tree is None:
raise ActionRefused(f"Node group {key!r} does not exist; createNodeGroup first.")
if tree.bl_idname != "GeometryNodeTree":
raise ActionRefused(f"Node group {key!r} is not a geometry node group.")
if getattr(tree, "library", None):
raise ActionRefused(f"Node group {key!r} is linked from a library and read-only.")
return tree
@staticmethod
def _node(tree: Any, node_id: str) -> Any:
for node in tree.nodes:
if node.get("brokkr_id") == node_id:
return node
raise ActionRefused(f"Node {node_id!r} does not exist in {tree.name!r}.")
@staticmethod
def _socket(sockets: Any, key: str, label: str) -> Any:
"""A socket of a node by identifier, else by display name when exactly one available socket has it."""
usable = [s for s in sockets if s.identifier and getattr(s, "enabled", True)]
for socket in usable:
if socket.identifier == key:
return socket
named = [s for s in usable if s.name == key]
if len(named) == 1:
return named[0]
raise ActionRefused(f"{label}: socket {key!r} " + ("is ambiguous by name; use its identifier." if named else "was not found."))
# actions
def _createNodeGroup(self, command: dict[str, Any]) -> tuple[str, str]:
key = tree_key(command)
interface = check_interface(command.get("interface"))
existing = self.bpy.data.node_groups.get(key)
if existing is not None:
raise ActionRefused(f"Node group {key!r} already exists.")
tree = self.bpy.data.node_groups.new(key, "GeometryNodeTree")
try:
made = []
for entry in interface:
socket = tree.interface.new_socket(entry["name"], in_out=entry["inOut"], socket_type=SOCKET_TYPES[entry["type"]])
if "default" in entry:
socket.default_value = entry["default"]
made.append(f"{entry['inOut'].lower()} {entry['name']}={socket.identifier}")
for node_id, idname in (("gin", "NodeGroupInput"), ("gout", "NodeGroupOutput")):
node = tree.nodes.new(idname)
node["brokkr_id"] = node_id
except Exception:
self.bpy.data.node_groups.remove(tree)
raise
return "Created node group; interface identifiers: " + ", ".join(made), tree.name
def _addNode(self, command: dict[str, Any]) -> tuple[str, str]:
tree = self._tree(tree_key(command))
node_id = text(command, "id", ID_PATTERN, "id")
idname = check_idname(command.get("blIdname"))
location = command.get("location", [0, 0])
if not isinstance(location, (list, tuple)) or len(location) != 2:
raise ActionRefused("location must be two numbers.")
location = [finite(part, "location") for part in location]
properties = command.get("properties") or {}
inputs = command.get("inputs") or {}
if not isinstance(properties, dict) or not isinstance(inputs, dict):
raise ActionRefused("properties and inputs must be objects.")
if any(n.get("brokkr_id") == node_id for n in tree.nodes):
raise ActionRefused(f"Node id {node_id!r} already exists in {tree.name!r}.")
if len(tree.nodes) >= MAX_NODES:
raise ActionRefused(f"Node group {tree.name!r} already has {MAX_NODES} nodes.")
try:
node = tree.nodes.new(idname)
except RuntimeError as error:
raise ActionRefused(f"Blender does not offer node type {idname}: {error}") from None
try:
node["brokkr_id"] = node_id
node.location = location
if command.get("label"):
node.label = str(command["label"])[:63]
for name, value in properties.items(): # node settings first: they decide which sockets exist
self._set_property(node, name, value)
for name, value in inputs.items():
socket = self._socket(node.inputs, name, f"input {name!r}")
if socket.is_linked or not hasattr(socket, "default_value"):
raise ActionRefused(f"input {name!r} has no settable default.")
socket.default_value = typed_value(value, socket.type, f"input {name!r}")
except Exception:
tree.nodes.remove(node)
raise
return "Node added.", node_id
def _set_property(self, node: Any, name: str, value: Any) -> None:
prop = node.bl_rna.properties.get(name) if isinstance(name, str) else None
if prop is None or prop.is_readonly or name in NODE_PROPERTY_DENIED or prop.type not in SETTABLE_PROPERTY_TYPES:
raise ActionRefused(f"{node.bl_idname} has no settable property {name!r}.")
if prop.type == "ENUM":
if not isinstance(value, str):
raise ActionRefused(f"property {name!r} needs an enum identifier.")
else:
value = typed_value(value, {"BOOLEAN": "BOOLEAN", "INT": "INT", "FLOAT": "FLOAT"}[prop.type], f"property {name!r}")
try:
setattr(node, name, value)
except (TypeError, ValueError) as error:
raise ActionRefused(f"property {name!r} cannot be {value!r}: {error}") from None
def _removeNode(self, command: dict[str, Any]) -> tuple[str, str]:
tree = self._tree(tree_key(command))
node_id = text(command, "id", ID_PATTERN, "id")
if node_id in ("gin", "gout"):
raise ActionRefused(f"{node_id!r} is the group's own input/output node and stays.")
node = next((n for n in tree.nodes if n.get("brokkr_id") == node_id), None)
if node is None:
return f"Node {node_id!r} was already absent.", node_id
tree.nodes.remove(node) # Blender drops the node's links with it
return "Node removed.", node_id
def _unlinkSockets(self, command: dict[str, Any]) -> tuple[str, str]:
tree = self._tree(tree_key(command))
target = self._node(tree, text(command, "toNode", ID_PATTERN, "toNode"))
into = self._socket(target.inputs, str(command.get("toSocket", "")), "toSocket")
source = out = None
if command.get("fromNode") or command.get("fromSocket"):
source = self._node(tree, text(command, "fromNode", ID_PATTERN, "fromNode"))
out = self._socket(source.outputs, str(command.get("fromSocket", "")), "fromSocket")
doomed = [l for l in tree.links if l.to_socket == into and (out is None or l.from_socket == out)]
for link in doomed:
tree.links.remove(link)
return f"Removed {len(doomed)} link(s).", tree.name
def _linkSockets(self, command: dict[str, Any]) -> tuple[str, str]:
tree = self._tree(tree_key(command))
source = self._node(tree, text(command, "fromNode", ID_PATTERN, "fromNode"))
target = self._node(tree, text(command, "toNode", ID_PATTERN, "toNode"))
out = self._socket(source.outputs, str(command.get("fromSocket", "")), "fromSocket")
into = self._socket(target.inputs, str(command.get("toSocket", "")), "toSocket")
if reaches(tree, target, source): # includes a self link
raise ActionRefused(f"Link {source.get('brokkr_id')} -> {target.get('brokkr_id')} would make a cycle.")
displaced = [(l.from_socket, l.is_muted) for l in tree.links if l.to_socket == into] if not getattr(into, "is_multi_input", False) else []
link = tree.links.new(out, into) # replaces whatever single link the input had
if not link.is_valid:
tree.links.remove(link)
for old, muted in displaced:
tree.links.new(old, into).is_muted = muted
raise ActionRefused(f"Link {out.name} -> {into.name} is not valid (type or field/single-value mismatch).")
return f"Linked {source.get('brokkr_id')}.{out.identifier} -> {target.get('brokkr_id')}.{into.identifier}.", tree.name
def _object(self, command: dict[str, Any]) -> Any:
name = command.get("objectName") or command.get("targetObjectName")
obj = self.bpy.data.objects.get(name) if name else None
if obj is None:
raise ActionRefused(f"Object {name!r} was not found.")
return obj
def _addModifier(self, command: dict[str, Any]) -> tuple[str, str]:
obj = self._object(command)
kind = command.get("modifierType", "NODES")
if kind != "NODES":
raise ActionRefused("modifierType must be NODES; other modifiers are not part of this cut.")
tree = self._tree(tree_key(command))
name = text(command, "modifierName", KEY_PATTERN, "modifierName", required=False) or "Brokkr GeoNodes"
modifier = obj.modifiers.get(name)
if modifier is not None and modifier.type != "NODES":
raise ActionRefused(f"{obj.name!r} already has a non-Geometry-Nodes modifier named {name!r}.")
if modifier is None:
if len(obj.modifiers) >= MAX_MODIFIERS:
raise ActionRefused(f"{obj.name!r} already has {MAX_MODIFIERS} modifiers.")
modifier = obj.modifiers.new(name, "NODES")
if modifier is None:
raise ActionRefused(f"{obj.name!r} is a {obj.type} object; it cannot take a Geometry Nodes modifier.")
modifier.node_group = tree
obj.update_tag()
return "Geometry Nodes modifier set.", obj.name
def _removeModifier(self, command: dict[str, Any]) -> tuple[str, str]:
obj = self._object(command)
# A lookup, not a creation: a modifier made by hand may have any name Blender allows.
name = command.get("modifierName") or "Brokkr GeoNodes"
if not isinstance(name, str) or len(name) > 255 or "\0" in name:
raise ActionRefused("modifierName must be a string of at most 255 characters.")
modifier = obj.modifiers.get(name)
if modifier is None:
return f"Modifier {name!r} was already absent.", obj.name
if modifier.type != "NODES":
raise ActionRefused(f"{name!r} is not a Geometry Nodes modifier; only those are removed here.")
obj.modifiers.remove(modifier)
obj.update_tag()
return "Modifier removed.", obj.name
def _setModifierInput(self, command: dict[str, Any]) -> tuple[str, str]:
obj = self._object(command)
modifier = obj.modifiers.get(str(command.get("modifierName") or "Brokkr GeoNodes"))
if modifier is None or modifier.type != "NODES" or modifier.node_group is None:
raise ActionRefused(f"{obj.name!r} has no Geometry Nodes modifier with a node group of that name.")
item = modifier_input(modifier.node_group, str(command.get("socket", "")))
value = typed_value(command.get("value"), item.socket_type.replace("NodeSocket", ""), f"input {item.name!r}")
# modifier.properties.inputs[id] is a raw IDPropertyGroup on 5.2.3; attribute access reaches the struct.
getattr(modifier.properties.inputs, item.identifier).value = value
obj.update_tag() # without it a headless re-evaluation returned the previous result
return f"Modifier input {item.name} set.", obj.name
def node_key(node: Any) -> Any:
"""A stable identity for a node: bpy hands out a fresh wrapper per access, so id() of one is reused by the next."""
return node.as_pointer() if hasattr(node, "as_pointer") else id(node)
def reaches(tree: Any, start: Any, goal: Any) -> bool:
"""True if following links forward from `start` arrives at `goal` (so a link goal -> start closes a cycle).
The adjacency is built once per call, so a call costs O(links)."""
forward: dict[Any, list[Any]] = {}
for link in tree.links:
forward.setdefault(node_key(link.from_node), []).append(link.to_node)
target = node_key(goal)
seen: set[Any] = set()
stack = [start]
while stack:
node = stack.pop()
key = node_key(node)
if key == target:
return True
if key in seen:
continue
seen.add(key)
stack.extend(forward.get(key, ()))
return False
def modifier_input(tree: Any, key: str) -> Any:
"""An INPUT socket of a group's interface, by identifier or by its unique name."""
items = [i for i in tree.interface.items_tree if i.item_type == "SOCKET" and i.in_out == "INPUT"]
for item in items:
if item.identifier == key:
return item
named = [i for i in items if i.name == key]
if len(named) == 1:
return named[0]
raise ActionRefused(f"Interface input {key!r} " + ("is ambiguous by name; use its identifier." if named else "was not found."))
def drives_modifier_input(obj: Any, property_name: str) -> str:
"""The modifier whose group has an interface input with identifier property_name (Socket_N), else ''.
An object custom property never drives a modifier input, so only the modifier's own identifiers are
refused; a property that merely shares an input's display name is an ordinary custom property."""
for modifier in obj.modifiers:
if modifier.type == "NODES" and modifier.node_group is not None:
for item in modifier.node_group.interface.items_tree:
if item.item_type == "SOCKET" and item.in_out == "INPUT" and property_name == item.identifier:
return modifier.name
return ""

View file

@ -98,7 +98,7 @@ def resolve_inside(root: str, candidate: str, label: str, *, new_file: bool = Fa
except ValueError: except ValueError:
raise ActionRefused(f"{label} path is outside the configured {label} directory.") from None raise ActionRefused(f"{label} path is outside the configured {label} directory.") from None
if new_file and os.path.lexists(current): if new_file and os.path.lexists(current):
raise ActionRefused(f"{label} path already exists; output actions never overwrite or write through an existing name.") raise ActionRefused(f"{label} path already exists; renderStill never overwrites or writes through an existing name.")
return current return current
@ -187,15 +187,6 @@ def integer(command: dict[str, Any], key: str, low: int, high: int) -> int | Non
return int(value) return int(value)
def unit_component(key: str, item: Any) -> float:
"""One colour component: a number in [0, 1]. The range test comes first: it also refuses nan and inf,
and a huge int (10**400) never reaches math.isfinite, which would raise OverflowError instead of
naming the field."""
if isinstance(item, bool) or not isinstance(item, (int, float)) or not 0.0 <= item <= 1.0:
raise ActionRefused(f"{key} components must be numbers between 0 and 1.")
return float(item)
def color(command: dict[str, Any], key: str = "color") -> tuple[float, float, float] | None: def color(command: dict[str, Any], key: str = "color") -> tuple[float, float, float] | None:
"""Linear RGB, three finite numbers in [0, 1], or None when absent.""" """Linear RGB, three finite numbers in [0, 1], or None when absent."""
if key not in command or command[key] is None: if key not in command or command[key] is None:
@ -205,23 +196,12 @@ def color(command: dict[str, Any], key: str = "color") -> tuple[float, float, fl
raise ActionRefused(f"{key} must be three numbers [r, g, b].") raise ActionRefused(f"{key} must be three numbers [r, g, b].")
parts = [] parts = []
for item in value: for item in value:
parts.append(unit_component(key, item)) if isinstance(item, bool) or not isinstance(item, (int, float)) or not math.isfinite(item) or not 0.0 <= item <= 1.0:
raise ActionRefused(f"{key} components must be numbers between 0 and 1.")
parts.append(float(item))
return (parts[0], parts[1], parts[2]) return (parts[0], parts[1], parts[2])
def color_rgba(command: dict[str, Any], key: str = "color") -> tuple[float, float, float, float] | None:
"""Linear RGB or RGBA, finite numbers in [0, 1] (RGB gets alpha 1), or None when absent."""
if key not in command or command[key] is None:
return None
value = command[key]
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
raise ActionRefused(f"{key} must be [r, g, b] or [r, g, b, a].")
parts = []
for item in value:
parts.append(unit_component(key, item))
return (parts[0], parts[1], parts[2], parts[3] if len(parts) == 4 else 1.0)
def choice(command: dict[str, Any], key: str, allowed: Iterable[str]) -> str | None: def choice(command: dict[str, Any], key: str, allowed: Iterable[str]) -> str | None:
if key not in command or command[key] is None: if key not in command or command[key] is None:
return None return None

View file

@ -108,7 +108,6 @@ Admitted command actions:
- `selectObject` - `selectObject`
- `assignMaterial` - `assignMaterial`
- `importGltf` - `importGltf`
- `exportGltf`
- `setCamera` - `setCamera`
- `setLight` - `setLight`
- `setWorld` - `setWorld`
@ -117,24 +116,9 @@ Admitted command actions:
Scene action fields (all other keys are ignored; a refused intent changes nothing and Scene action fields (all other keys are ignored; a refused intent changes nothing and
gets a `failed` receipt whose message names the rule): gets a `failed` receipt whose message names the rule):
- `createObject`: optional `objectType` (`MESH`, `LIGHT`, `CAMERA`, `EMPTY`; default `EMPTY`),
`name`, `location`. Any other `objectType` (for example `TEXT` or `CURVE`) is refused with
the allowed set named. A `MESH` is a cube (`size`); a `meshType` other than `CUBE`
(case-insensitive, no surrounding whitespace) is refused rather than ignored.
`meshType` is checked only when `objectType` is `MESH`; an explicit `objectType: null` is refused.
- `importGltf`: `filepath`, `.gltf` or `.glb`, relative to the add-on preference - `importGltf`: `filepath`, `.gltf` or `.glb`, relative to the add-on preference
`Import Directory` (absolute paths, `..` and symlinks that resolve outside it are `Import Directory` (absolute paths, `..` and symlinks that resolve outside it are
refused). Empty `Import Directory` disables the action. Needs Blender's glTF add-on. refused). Empty `Import Directory` disables the action. Needs Blender's glTF add-on.
- `exportGltf`: `filepath`, `.glb` (binary) or `.gltf` (separate `.bin`), relative to the
add-on preference `Render Output Directory` (the renderStill rules: no `..`, no links, and an
existing name, or an existing `.bin` beside a `.gltf`, is refused; there is no overwrite).
Optional `extras` (custom properties become node `extras`), `yUp`, `applyModifiers`
(each true by default, must be a boolean) and `selection` (a non-empty list of object
names; any name not found, or not in the active view layer, refuses the export; the
selection is touched only when one is given, and restored afterwards). Not checked: texture
files a `.gltf` export writes beside it (only the `.bin` is), and a link or swap made between the
check and the write (the same residual as renderStill).
The receipt message carries the written path and byte size. Needs Blender's glTF add-on.
- `setCamera`: `targetObjectName` (a camera object); optional `lens` (mm), - `setCamera`: `targetObjectName` (a camera object); optional `lens` (mm),
`cameraType` (`PERSP`, `ORTHO`, `PANO`), `panoramaType` (for example `cameraType` (`PERSP`, `ORTHO`, `PANO`), `panoramaType` (for example
`EQUIRECTANGULAR`; read from the camera, or from `camera.cycles` before Blender 4.2), `EQUIRECTANGULAR`; read from the camera, or from `camera.cycles` before Blender 4.2),
@ -144,26 +128,12 @@ gets a `failed` receipt whose message names the rule):
`color` (linear `[r, g, b]`, each 0 to 1), `lightType` (`POINT`, `SUN`, `SPOT`, `AREA`). `color` (linear `[r, g, b]`, each 0 to 1), `lightType` (`POINT`, `SUN`, `SPOT`, `AREA`).
- `setWorld`: optional `worldName` (default: the scene's world, created if there is - `setWorld`: optional `worldName` (default: the scene's world, created if there is
none), `color` (`[r, g, b]`), `strength`. Edits the world's Background node. none), `color` (`[r, g, b]`), `strength`. Edits the world's Background node.
- `assignMaterial`: `targetObjectName` (an object with material slots, such as a mesh);
optional `materialName` (default `Brokkr Material`; an existing material of that name
is reused) and `color` (linear `[r, g, b]` or `[r, g, b, a]`, each a finite number 0
to 1; alpha defaults to 1). `color` sets the Principled BSDF Base Color (the node tree
is created if the material has none) and `diffuse_color`; a malformed `color` is
refused naming the field and nothing is created or changed (`"color": null` counts as absent). Without `color` an
existing material's colour is left as it is. Idempotent per (object, material): if the
object already has a slot with that material no second slot is added. Other keys are
ignored, as for every action.
- `renderStill`: `outputPath` relative to the preference `Render Output Directory`, - `renderStill`: `outputPath` relative to the preference `Render Output Directory`,
ending `.png`, `.jpg` or `.exr` (same containment rules as import); optional ending `.png`, `.jpg` or `.exr` (same containment rules as import); optional
`engine` (any engine of the running Blender, case-insensitive), `resolutionX`, `engine` (any engine of the running Blender, case-insensitive), `resolutionX`,
`resolutionY` (whole numbers, 1 to 16384), `cameraName`. Engine, resolution, `resolutionY` (whole numbers, 1 to 16384), `cameraName`. Engine, resolution,
camera, file path and format are restored after the render. The render blocks camera, file path and format are restored after the render. The render blocks
Blender's UI until it finishes. In a background (`-b`) Blender on Linux, the GL Blender's UI until it finishes.
engines `BLENDER_WORKBENCH`, `BLENDER_EEVEE` and `BLENDER_EEVEE_NEXT` are refused
with a `failed` receipt when `libEGL.so.1` cannot be loaded (probed once and cached for the life of the process), because
rendering would abort the whole process with no receipt; `CYCLES` renders on the
CPU. The check applies to the scene's engine when `engine` is omitted. No such
check runs on Windows or macOS.
The scene actions were run against Blender 5.2.3 LTS headless with The scene actions were run against Blender 5.2.3 LTS headless with
`surfaces/blender/tests/live/headless_smoke.py` (usage in its docstring). One real `surfaces/blender/tests/live/headless_smoke.py` (usage in its docstring). One real

View file

@ -1,39 +0,0 @@
# Geometry node graph actions
Command intents (`brokkr.blender.command_intent.v0`) that build and drive a Geometry Nodes graph.
Owner: `brokkr_bridge/node_actions.py`; the target only dispatches. Every field is validated before
`bpy` is touched; a refused intent is a `failed` receipt naming the rule and leaves the tree as it was.
Design and the parts not built yet: the design lives at tag `parked/blender-node-graphs-design` (branch `libby/blender-node-graphs-design`, PR #3, closed unmerged), not in this tree.
| action | fields | rule |
|---|---|---|
| `createNodeGroup` | `treeKey`, `interface` `[{inOut, name, type, default?}]` | New geometry node group with `gin` and `gout` nodes. Types: Geometry, Float, Int, Bool, Vector. Refuses an existing group. The receipt lists the interface identifiers (`Socket_N`). |
| `addNode` | `treeKey`, `id`, `blIdname`, `location?`, `label?`, `properties?`, `inputs?` | `id` is Brokkr's key (stored as the node's `brokkr_id`). Type must be a `GeometryNode*`, `FunctionNode*` or `ShaderNode*` name that is not an importer, exporter, script, bake, group, zone, viewer or image-texture node. Properties are enum/bool/int/float node settings (set first, they decide which sockets exist); inputs are unlinked defaults (float, int, bool, vector, colour). At most 500 nodes per group. |
| `removeNode` | `treeKey`, `id` | Removes the node and its links. Absent id: accepted. `gin` and `gout` stay. |
| `linkSockets` | `treeKey`, `fromNode`, `fromSocket`, `toNode`, `toSocket` | Sockets by identifier, or by display name when exactly one available socket has it (Group Input/Output sockets are addressed by interface name). A link Blender calls invalid (field into a single value, type mismatch) is removed and refused, and the single link it displaced is put back. A valid link into a single-link input replaces the old one (Blender's behaviour); a link that would close a cycle, or link a node to itself, is refused before Blender is asked. |
| `unlinkSockets` | `treeKey`, `toNode`, `toSocket`, `fromNode?`, `fromSocket?` | Removes the links into an input, or only the one from the given source. None to remove: accepted. |
| `addModifier` | `objectName`, `treeKey`, `modifierName?` (default `Brokkr GeoNodes`) | `NODES` only. Reuses a modifier of that name; at most 8 per object. |
| `removeModifier` | `objectName`, `modifierName?` | Geometry Nodes modifiers only (a Subdivision or other modifier is refused). Absent: accepted. |
| `setModifierInput` | `objectName`, `modifierName?`, `socket` (identifier or unique interface name), `value` | Float, Int, Bool, Vector inputs. Written as `getattr(modifier.properties.inputs, id).value = v` (indexing `inputs[id]` yields a raw ID property group without `.value` on 5.2.3), then `object.update_tag()`. |
`setObjectCustomProperty` refuses a property whose name is the identifier (`Socket_N`) of an input of a Geometry
Nodes modifier on that object; a custom property never drives a modifier input. A name that only equals an
input's display name is an ordinary custom property.
The host snapshot's object entries carry `evaluated` for mesh objects with modifiers: vertex, edge and
polygon counts, `instanceCount`, and up to 256 vertex and instance locations (`truncated` when cut). Vertex
positions and `vertexBounds*` are in the object's local space; instance locations and `instanceBounds*` are
world space. Above 1,000,000 vertices only counts are read (`positionsSkipped`); a non-finite coordinate sets
`nonFinite` and drops the positions and bounds. The instances come from one depsgraph scan per capture.
`instancesComplete` is false when the depsgraph scan stopped at 100,000 items (`instanceCount` and the instance bounds are then lower bounds); the scan is what costs time (about 5 microseconds per instance in Python), so a 2000 x 2000 grid instanced on points captures in 1.4 s on the test machine where an unbounded scan took 22 s. `instanceCount` is `null` for an object the stopped scan never reached (unknown, not zero); the 100,000 budget is shared across all objects in depsgraph order, so a host after a heavy one can be starved. Otherwise `instanceCount` counts evaluated instances whose parent is the object, so a curve, point or volume result
also reads as one instance. `mesh` stays the base mesh.
Known edges: a node duplicated by hand copies its `brokkr_id`, so id lookups act on the first match; `gin`/`gout` are protected by id, and a group whose output node was deleted by hand cannot get a new one through `addNode`; removing a group's last modifier does not delete the group (it may stay in the file until it is saved and reloaded; `addModifier` can reuse it); `removeModifier` takes any modifier name Blender allows because it only looks the modifier up.
Not built: zones, item sockets, multi-input ordering, library groups, group-in-group nodes, batches/rollback across
intents, an expectedRevision check, per-node cost estimates. Int values are capped at +-4096 and floats at +-1e9,
which bounds a request but not the cost of a graph: Blender evaluates what it is given, and the snapshot evaluates
every mesh with modifiers on each capture (mesh grids measured: 16.7M vertices evaluate and read in 0.4 s).
`addModifier` refuses object types that take no modifiers.
Live proof: `tests/live/gn_actions.py` (Blender 5.2.3, headless). Unit tests: `tests/test_node_actions.py`.

View file

@ -1,269 +0,0 @@
"""Live proof of the node-graph actions: rebuild three geometry-node setups through Brokkr actions only.
BROKKR_ADDON=<path to surfaces/blender/brokkr_bridge> \
blender -b --factory-startup --python gn_actions.py -- result.json
Each native tree is built with bpy as the reference and evaluated with the depsgraph. The Brokkr side
uses BrokkrBlenderTarget.execute_command for every build step and capture_snapshot().objects[].evaluated
for every read: no bpy call touches the Brokkr-built scene except the harness's own reference trees.
Exit status is non-zero if any check fails.
"""
import bpy, sys, os, json, types, math
from pathlib import Path
ADDON = Path(os.environ["BROKKR_ADDON"]).resolve()
pkg = types.ModuleType("brokkr_bridge"); pkg.__path__ = [str(ADDON)]
sys.modules["brokkr_bridge"] = pkg
from brokkr_bridge import blender_target as bt
out = sys.argv[sys.argv.index("--") + 1] if "--" in sys.argv else "gn_actions_result.json"
R = {"blender": bpy.app.version_string, "checks": []}
def check(name, ok, detail=None):
R["checks"].append({"name": name, "ok": bool(ok), "detail": detail}); print("CHECK", "ok " if ok else "FAIL", name, detail if not ok else "", flush=True)
def fresh():
bpy.ops.wm.read_factory_settings(use_empty=True)
return bt.BrokkrBlenderTarget(bpy)
N = [0]
def act(t, **cmd):
N[0] += 1; cmd.setdefault("commandId", "c%d" % N[0])
r = t.execute_command(bpy.context, cmd)
if r["status"] != "accepted": print("REFUSED", cmd["action"], r["message"], flush=True)
return r
def ok(t, **cmd):
r = act(t, **cmd); check("%s %s" % (cmd["action"], cmd.get("id") or cmd.get("treeKey") or cmd.get("objectName") or ""), r["status"] == "accepted", r["message"]); return r
def evaluated(t, name):
snap = t.capture_snapshot(bpy.context)
return [o for o in snap["objects"] if o["name"] == name][0]["evaluated"]
def native_tree(build):
g = bpy.data.node_groups.new("native", "GeometryNodeTree")
g.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
gout = g.nodes.new("NodeGroupOutput"); build(g, gout)
me = bpy.data.meshes.new("nm"); ob = bpy.data.objects.new("native_host", me); bpy.context.scene.collection.objects.link(ob)
ob.modifiers.new("GN", "NODES").node_group = g
dg = bpy.context.evaluated_depsgraph_get(); dg.update()
ev = ob.evaluated_get(dg); m = ev.to_mesh()
verts = sorted(tuple(round(c, 5) for c in v.co) for v in m.vertices); ev.to_mesh_clear()
inst = sorted(tuple(round(c, 5) for c in i.matrix_world.translation) for i in dg.object_instances if i.is_instance)
bpy.data.objects.remove(ob); bpy.data.node_groups.remove(g); bpy.data.meshes.remove(me)
return verts, inst
def srt(rows): return sorted(tuple(r) for r in rows)
def host(t, name, tree, mod="GN"):
ok(t, action="createObject", objectType="MESH", name=name)
ok(t, action="addModifier", objectName=name, modifierName=mod, treeKey=tree)
def graph(t, tree, interface, nodes, links):
ok(t, action="createNodeGroup", treeKey=tree, interface=interface)
for n in nodes: ok(t, action="addNode", treeKey=tree, **n)
for a, b, c, d in links: ok(t, action="linkSockets", treeKey=tree, fromNode=a, fromSocket=b, toNode=c, toSocket=d)
GEO_OUT = [{"inOut": "OUTPUT", "name": "Geometry", "type": "Geometry"}]
# ---- 1. ring of instances
def ring(g, gout):
c = g.nodes.new("GeometryNodeMeshCircle"); c.inputs["Vertices"].default_value = 8; c.inputs["Radius"].default_value = 2.0
cu = g.nodes.new("GeometryNodeMeshCube"); cu.inputs["Size"].default_value = (0.3, 0.3, 0.3)
i = g.nodes.new("GeometryNodeInstanceOnPoints")
g.links.new(c.outputs["Mesh"], i.inputs["Points"]); g.links.new(cu.outputs["Mesh"], i.inputs["Instance"]); g.links.new(i.outputs["Instances"], gout.inputs["Geometry"])
nv, ni = native_tree(ring)
t = fresh(); N[0] = 0
graph(t, "Ring", GEO_OUT,
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Vertices": 8, "Radius": 2.0}),
dict(id="cube", blIdname="GeometryNodeMeshCube", inputs={"Size": [0.3, 0.3, 0.3]}),
dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
[("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
host(t, "RingHost", "Ring")
ev = evaluated(t, "RingHost")
check("ring instances equal native", srt(ev["instanceLocations"]) == ni and ev["instanceCount"] == 8, {"brokkr": ev["instanceLocations"], "native": ni})
# parameter change by setModifierInput on an exposed input: rebuild with an exposed Count
t = fresh()
graph(t, "RingN", GEO_OUT + [{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}],
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Radius": 2.0}),
dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
[("gin", "Count", "circle", "Vertices"), ("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
host(t, "RingHost", "RingN")
before = evaluated(t, "RingHost")["instanceCount"]
ok(t, action="setModifierInput", objectName="RingHost", modifierName="GN", socket="Count", value=12)
after = evaluated(t, "RingHost")["instanceCount"]
check("setModifierInput changes evaluated instances", (before, after) == (8, 12), (before, after))
r = act(t, action="setObjectCustomProperty", targetObjectName="RingHost", propertyName="Socket_1", value=3)
check("custom property naming a modifier input identifier is refused", r["status"] == "failed" and "setModifierInput" in r["message"], r["message"])
check("refused custom property changed nothing", evaluated(t, "RingHost")["instanceCount"] == 12)
r = act(t, action="setObjectCustomProperty", targetObjectName="RingHost", propertyName="Count", value=3)
check("a custom property sharing only a display name is ordinary", r["status"] == "accepted", r["message"])
# ---- 2. boundary step
B = 0.5
def boundary(g, gout):
ln = g.nodes.new("GeometryNodeMeshLine"); ln.inputs["Count"].default_value = 3; ln.inputs["Offset"].default_value = (2, 0, 0)
pos = g.nodes.new("GeometryNodeInputPosition")
d = g.nodes.new("ShaderNodeVectorMath"); d.operation = "LENGTH"
c = g.nodes.new("FunctionNodeCompare"); c.data_type = "FLOAT"; c.operation = "GREATER_THAN"; c.inputs["B"].default_value = B
nm = g.nodes.new("ShaderNodeVectorMath"); nm.operation = "NORMALIZE"
sc = g.nodes.new("ShaderNodeVectorMath"); sc.operation = "SCALE"; sc.inputs["Scale"].default_value = B
sp = g.nodes.new("GeometryNodeSetPosition")
g.links.new(pos.outputs[0], d.inputs[0]); g.links.new(d.outputs["Value"], c.inputs["A"]); g.links.new(pos.outputs[0], nm.inputs[0]); g.links.new(nm.outputs[0], sc.inputs[0])
g.links.new(ln.outputs["Mesh"], sp.inputs["Geometry"]); g.links.new(c.outputs["Result"], sp.inputs["Selection"]); g.links.new(sc.outputs[0], sp.inputs["Position"]); g.links.new(sp.outputs["Geometry"], gout.inputs["Geometry"])
nv, ni = native_tree(boundary)
t = fresh()
graph(t, "Boundary", GEO_OUT,
[dict(id="line", blIdname="GeometryNodeMeshLine", inputs={"Count": 3, "Offset": [2, 0, 0]}),
dict(id="pos", blIdname="GeometryNodeInputPosition"),
dict(id="len", blIdname="ShaderNodeVectorMath", properties={"operation": "LENGTH"}),
dict(id="gt", blIdname="FunctionNodeCompare", properties={"data_type": "FLOAT", "operation": "GREATER_THAN"}, inputs={"B": B}),
dict(id="nrm", blIdname="ShaderNodeVectorMath", properties={"operation": "NORMALIZE"}),
dict(id="edge", blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": B}),
dict(id="set", blIdname="GeometryNodeSetPosition")],
[("pos", "Position", "len", "Vector"), ("len", "Value", "gt", "A"), ("pos", "Position", "nrm", "Vector"), ("nrm", "Vector", "edge", "Vector"),
("line", "Mesh", "set", "Geometry"), ("gt", "Result", "set", "Selection"), ("edge", "Vector", "set", "Position"), ("set", "Geometry", "gout", "Geometry")])
host(t, "BoundaryHost", "Boundary")
ev = evaluated(t, "BoundaryHost")
check("boundary_step vertices equal native", srt(ev["vertexLocations"]) == nv, {"brokkr": ev["vertexLocations"], "native": nv})
# ---- 3. seeded scatter
def scatter(g, gout):
gr = g.nodes.new("GeometryNodeMeshGrid"); gr.inputs["Size X"].default_value = 3; gr.inputs["Size Y"].default_value = 3
di = g.nodes.new("GeometryNodeDistributePointsOnFaces"); di.distribute_method = "RANDOM"; di.inputs["Density"].default_value = 4; di.inputs["Seed"].default_value = 7
co = g.nodes.new("GeometryNodeMeshCone"); co.inputs["Radius Bottom"].default_value = 0.05; co.inputs["Depth"].default_value = 0.4
i = g.nodes.new("GeometryNodeInstanceOnPoints")
g.links.new(gr.outputs["Mesh"], di.inputs["Mesh"]); g.links.new(di.outputs["Points"], i.inputs["Points"]); g.links.new(co.outputs["Mesh"], i.inputs["Instance"]); g.links.new(i.outputs["Instances"], gout.inputs["Geometry"])
nv, ni = native_tree(scatter)
t = fresh()
graph(t, "Scatter", GEO_OUT + [{"inOut": "INPUT", "name": "Seed", "type": "Int", "default": 7}],
[dict(id="grid", blIdname="GeometryNodeMeshGrid", inputs={"Size X": 3, "Size Y": 3}),
dict(id="dist", blIdname="GeometryNodeDistributePointsOnFaces", properties={"distribute_method": "RANDOM"}, inputs={"Density": 4, "Seed": 7}),
dict(id="cone", blIdname="GeometryNodeMeshCone", inputs={"Radius Bottom": 0.05, "Depth": 0.4}),
dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
[("grid", "Mesh", "dist", "Mesh"), ("dist", "Points", "iop", "Points"), ("cone", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")])
host(t, "ScatterHost", "Scatter")
ev = evaluated(t, "ScatterHost")
check("scatter instances equal native", srt(ev["instanceLocations"]) == ni and ev["instanceCount"] == len(ni), {"brokkr": ev["instanceCount"], "native": len(ni)})
ok(t, action="setModifierInput", objectName="ScatterHost", modifierName="GN", socket="Seed", value=7) # exposed but unwired: no effect
check("unwired exposed input leaves result", srt(evaluated(t, "ScatterHost")["instanceLocations"]) == ni)
# ---- refusals on the real API
r = act(t, action="addNode", treeKey="Scatter", id="bad", blIdname="GeometryNodeMeshCube", inputs={"Nope": 1}); check("unknown input refused", r["status"] == "failed", r["message"])
check("refused addNode left no node", not any(n.get("brokkr_id") == "bad" for n in bpy.data.node_groups["Scatter"].nodes))
r = act(t, action="addNode", treeKey="Scatter", id="bad2", blIdname="GeometryNodeMeshCube", properties={"nope": 1}); check("unknown property refused", r["status"] == "failed", r["message"])
r = act(t, action="addNode", treeKey="Scatter", id="bad3", blIdname="GeometryNodeImportOBJ"); check("file-reading node refused", r["status"] == "failed", r["message"])
r = act(t, action="addNode", treeKey="Scatter", id="bad4", blIdname="GeometryNodeNoSuchThing"); check("unknown node type refused", r["status"] == "failed", r["message"])
r = act(t, action="linkSockets", treeKey="Scatter", fromNode="cone", fromSocket="Mesh", toNode="dist", toSocket="Density"); check("invalid link refused and removed", r["status"] == "failed" and not any(l.to_socket.name == "Density" for l in bpy.data.node_groups["Scatter"].links), r["message"])
r = act(t, action="setModifierInput", objectName="ScatterHost", modifierName="GN", socket="Seed", value="x"); check("wrong value type refused", r["status"] == "failed", r["message"])
r = act(t, action="addNode", treeKey="Scatter", id="grid", blIdname="GeometryNodeMeshCube"); check("duplicate id refused", r["status"] == "failed", r["message"])
t2 = fresh()
graph(t2, "L", GEO_OUT, [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="m", blIdname="ShaderNodeMath"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")],
[("cube", "Mesh", "iop", "Points")])
before = [(l.from_node.get("brokkr_id"), l.to_socket.identifier) for l in bpy.data.node_groups["L"].links if l.to_node.get("brokkr_id") == "iop"]
r = act(t2, action="linkSockets", treeKey="L", fromNode="m", fromSocket="Value", toNode="iop", toSocket="Points")
after = [(l.from_node.get("brokkr_id"), l.to_socket.identifier) for l in bpy.data.node_groups["L"].links if l.to_node.get("brokkr_id") == "iop"]
check("refused link keeps the link it would have replaced", r["status"] == "failed" and before == after == [("cube", "Points")], (r["message"], before, after))
ok(t2, action="createObject", objectType="CAMERA", name="Cam")
r = act(t2, action="addModifier", objectName="Cam", treeKey="L"); check("modifier on a camera refused clearly", r["status"] == "failed" and "CAMERA" in r["message"], r["message"])
r = act(t2, action="addNode", treeKey="L", id="n\n", blIdname="ShaderNodeMath"); check("trailing newline in an id refused", r["status"] == "failed", r["message"])
# off-origin host: vertices are local, instances world, bounds kept apart
t3 = fresh()
graph(t3, "Off", GEO_OUT, [dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Vertices": 4, "Radius": 1.0}), dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints"), dict(id="join", blIdname="GeometryNodeJoinGeometry")],
[("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "join", "Geometry"), ("circle", "Mesh", "join", "Geometry"), ("join", "Geometry", "gout", "Geometry")])
ok(t3, action="createObject", objectType="MESH", name="OffHost", location=[100, 0, 0]); ok(t3, action="addModifier", objectName="OffHost", modifierName="GN", treeKey="Off")
ev = evaluated(t3, "OffHost")
check("local vertex bounds and world instance bounds are separate", max(ev["vertexBoundsMax"]) <= 1.01 and ev["instanceBoundsMin"][0] >= 98.9 and ev["instanceCount"] == 4, ev)
# non-finite positions are flagged
ok(t3, action="createNodeGroup", treeKey="Inf", interface=GEO_OUT)
for i in range(6): ok(t3, action="addNode", treeKey="Inf", id="s%d" % i, blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": 1e9, "Vector": [1, 1, 1]}) if i == 0 else ok(t3, action="addNode", treeKey="Inf", id="s%d" % i, blIdname="ShaderNodeVectorMath", properties={"operation": "SCALE"}, inputs={"Scale": 1e9})
ok(t3, action="addNode", treeKey="Inf", id="cube", blIdname="GeometryNodeMeshCube"); ok(t3, action="addNode", treeKey="Inf", id="set", blIdname="GeometryNodeSetPosition")
for i in range(1, 6): ok(t3, action="linkSockets", treeKey="Inf", fromNode="s%d" % (i - 1), fromSocket="Vector", toNode="s%d" % i, toSocket="Vector")
ok(t3, action="linkSockets", treeKey="Inf", fromNode="cube", fromSocket="Mesh", toNode="set", toSocket="Geometry"); ok(t3, action="linkSockets", treeKey="Inf", fromNode="s5", fromSocket="Vector", toNode="set", toSocket="Position"); ok(t3, action="linkSockets", treeKey="Inf", fromNode="set", fromSocket="Geometry", toNode="gout", toSocket="Geometry")
ok(t3, action="createObject", objectType="MESH", name="InfHost"); ok(t3, action="addModifier", objectName="InfHost", treeKey="Inf")
ev = evaluated(t3, "InfHost")
check("non-finite evaluated positions are flagged and not published", ev["nonFinite"] and ev["vertexLocations"] == [] and json.dumps(ev, allow_nan=False), ev)
# ---- removal, unlinking, cycles (cut 2)
import time
t4 = fresh()
graph(t4, "R", GEO_OUT + [{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 4}],
[dict(id="circle", blIdname="GeometryNodeMeshCircle", inputs={"Radius": 1.0}), dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints"),
dict(id="m1", blIdname="ShaderNodeMath"), dict(id="m2", blIdname="ShaderNodeMath")],
[("gin", "Count", "circle", "Vertices"), ("circle", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry"), ("m1", "Value", "m2", "Value")])
host(t4, "RHost", "R")
check("graph evaluates before removal", evaluated(t4, "RHost")["instanceCount"] == 4)
for src, dst in (("m2", "m1"), ("m1", "m1")):
r = act(t4, action="linkSockets", treeKey="R", fromNode=src, fromSocket="Value", toNode=dst, toSocket="Value_001"); check("cycle %s->%s refused" % (src, dst), r["status"] == "failed" and "cycle" in r["message"], r["message"])
check("refused cycle left the link set alone", len(bpy.data.node_groups["R"].links) == 5)
r = ok(t4, action="unlinkSockets", treeKey="R", fromNode="m1", fromSocket="Value", toNode="m2", toSocket="Value")
check("unlinked exactly one link", len(bpy.data.node_groups["R"].links) == 4, r["message"])
r = ok(t4, action="unlinkSockets", treeKey="R", toNode="m2", toSocket="Value"); check("unlink of nothing is accepted", "Removed 0" in r["message"], r["message"])
ok(t4, action="unlinkSockets", treeKey="R", toNode="iop", toSocket="Instance")
check("unlinking the instance changes the result", evaluated(t4, "RHost")["instanceCount"] == 0, evaluated(t4, "RHost"))
ok(t4, action="linkSockets", treeKey="R", fromNode="cube", fromSocket="Mesh", toNode="iop", toSocket="Instance")
check("relinked", evaluated(t4, "RHost")["instanceCount"] == 4)
links_before = len(bpy.data.node_groups["R"].links)
ok(t4, action="removeNode", treeKey="R", id="cube")
check("removeNode drops the node and its links", not any(n.get("brokkr_id") == "cube" for n in bpy.data.node_groups["R"].nodes) and len(bpy.data.node_groups["R"].links) == links_before - 1)
r = act(t4, action="removeNode", treeKey="R", id="gout"); check("group output node stays", r["status"] == "failed", r["message"])
check("removeNode of an absent id is accepted", act(t4, action="removeNode", treeKey="R", id="cube")["status"] == "accepted")
ok(t4, action="removeModifier", objectName="RHost", modifierName="GN")
check("removeModifier removes it; evaluated block gone", len(bpy.data.objects["RHost"].modifiers) == 0 and evaluated(t4, "RHost") is None)
bpy.data.objects["RHost"].modifiers.new("Sub", "SUBSURF")
r = act(t4, action="removeModifier", objectName="RHost", modifierName="Sub"); check("non-Geometry-Nodes modifier is not removed", r["status"] == "failed" and len(bpy.data.objects["RHost"].modifiers) == 1, r["message"])
# ---- cycle detection on a long chain (bpy hands out a fresh wrapper per access; id() would be reused)
t5 = fresh()
ok(t5, action="createNodeGroup", treeKey="C", interface=GEO_OUT)
ids = ["c%d" % i for i in range(60)]
for i in ids: act(t5, action="addNode", treeKey="C", id=i, blIdname="ShaderNodeMath")
for a_, b_ in zip(ids, ids[1:]): act(t5, action="linkSockets", treeKey="C", fromNode=a_, fromSocket="Value", toNode=b_, toSocket="Value")
for a_, b_ in zip(ids[:-2], ids[2:]): act(t5, action="linkSockets", treeKey="C", fromNode=a_, fromSocket="Value", toNode=b_, toSocket="Value_001")
before = len(bpy.data.node_groups["C"].links)
t0 = time.time()
refused = [act(t5, action="linkSockets", treeKey="C", fromNode=ids[-1], fromSocket="Value", toNode=h, toSocket="Value_002")["status"] == "failed" for h in ids[:-1]]
R["cycle_probe_seconds"] = round(time.time() - t0, 3)
check("every closing link on a 60-node chain with skip links is refused", all(refused) and len(bpy.data.node_groups["C"].links) == before, (sum(refused), len(refused), R["cycle_probe_seconds"]))
# ---- fix-pass checks: muted state survives a refused link; hand-made Unicode modifier names are removable
t6 = fresh()
graph(t6, "Mu", GEO_OUT, [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="m", blIdname="ShaderNodeMath"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")], [("cube", "Mesh", "iop", "Points")])
for l in bpy.data.node_groups["Mu"].links:
if l.to_node.get("brokkr_id") == "iop": l.is_muted = True
r = act(t6, action="linkSockets", treeKey="Mu", fromNode="m", fromSocket="Value", toNode="iop", toSocket="Points")
muted = [l.is_muted for l in bpy.data.node_groups["Mu"].links if l.to_node.get("brokkr_id") == "iop"]
check("refused link restores the displaced link's muted state", r["status"] == "failed" and muted == [True], (r["message"], muted))
ok(t6, action="createObject", objectType="MESH", name="U")
bpy.data.objects["U"].modifiers.new("G\u00e9 x", "NODES")
r = act(t6, action="removeModifier", objectName="U", modifierName="G\u00e9 x")
check("hand-made Unicode modifier name is removable", r["status"] == "accepted" and len(bpy.data.objects["U"].modifiers) == 0, r["message"])
# ---- cost: a heavy graph must not stall capture
def heavy(n, instanced):
tt = fresh()
nodes = [dict(id="grid", blIdname="GeometryNodeMeshGrid", inputs={"Vertices X": n, "Vertices Y": n})]
links = []
if instanced:
nodes += [dict(id="cube", blIdname="GeometryNodeMeshCube"), dict(id="iop", blIdname="GeometryNodeInstanceOnPoints")]
links = [("grid", "Mesh", "iop", "Points"), ("cube", "Mesh", "iop", "Instance"), ("iop", "Instances", "gout", "Geometry")]
else:
links = [("grid", "Mesh", "gout", "Geometry")]
graph(tt, "Heavy", GEO_OUT, nodes, links); host(tt, "HeavyHost", "Heavy")
t0 = time.time(); snap = tt.capture_snapshot(bpy.context); dt = time.time() - t0
return dt, [o for o in snap["objects"] if o["name"] == "HeavyHost"][0]["evaluated"]
dt, ev = heavy(2000, True)
R["capture_2000_grid_instanced_seconds"] = round(dt, 3)
check("2000x2000 instanced grid: capture is bounded", dt < 3.0 and ev["instancesComplete"] is False and ev["instanceCount"] <= 100000, (round(dt, 2), ev["instanceCount"], ev["instancesComplete"]))
dt, ev = heavy(2000, False)
R["capture_2000_grid_mesh_seconds"] = round(dt, 3)
check("2000x2000 mesh grid: counts only, bounded", dt < 3.0 and ev["vertexCount"] == 4000000 and ev["positionsSkipped"], (round(dt, 2), ev["vertexCount"]))
json.dump(R, open(out, "w"), indent=1, default=str)
bad = [c for c in R["checks"] if not c["ok"]]
print("DONE", len(R["checks"]), "checks,", len(bad), "failed")
sys.exit(1 if bad else 0)

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,55 +0,0 @@
"""Live proof of assignMaterial colour and idempotence on real bpy.
BROKKR_ADDON=<path to surfaces/blender/brokkr_bridge> \
blender -b --factory-startup --python material_colour.py -- result.json
Exit status is non-zero if any check fails.
"""
import bpy, sys, os, json, types
from pathlib import Path
ADDON = Path(os.environ["BROKKR_ADDON"]).resolve()
pkg = types.ModuleType("brokkr_bridge"); pkg.__path__ = [str(ADDON)]
sys.modules["brokkr_bridge"] = pkg
from brokkr_bridge import blender_target as bt
out = sys.argv[sys.argv.index("--") + 1] if "--" in sys.argv else "material_colour_result.json"
R = {"blender": bpy.app.version_string, "checks": []}
def check(name, ok, detail=None):
R["checks"].append({"name": name, "ok": bool(ok), "detail": detail}); print("CHECK", "ok " if ok else "FAIL", name, detail if not ok else "", flush=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
t = bt.BrokkrBlenderTarget(bpy)
bpy.ops.mesh.primitive_cube_add()
cube = bpy.context.active_object
N = [0]
def act(**cmd):
N[0] += 1; cmd.setdefault("commandId", "c%d" % N[0]); cmd["action"] = "assignMaterial"; cmd["targetObjectName"] = cube.name
return t.execute_command(bpy.context, cmd)
def base():
return tuple(round(c, 6) for c in bpy.data.materials["oxide red"].node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value)
r = act(materialName="oxide red", color=[0.56, 0.2, 0.12])
check("oxide red accepted", r["status"] == "accepted", r["message"])
check("principled base color", base() == (0.56, 0.2, 0.12, 1.0), base())
m = bpy.data.materials["oxide red"]
check("diffuse_color", tuple(round(c, 6) for c in m.diffuse_color) == (0.56, 0.2, 0.12, 1.0), tuple(m.diffuse_color))
check("one slot", len(cube.material_slots) == 1, len(cube.material_slots))
r = act(materialName="oxide red", color=[0.56, 0.2, 0.12])
check("repeat accepted", r["status"] == "accepted", r["message"])
check("repeat keeps one slot", len(cube.material_slots) == 1, len(cube.material_slots))
r = act(materialName="oxide red")
check("no color leaves colour", r["status"] == "accepted" and base() == (0.56, 0.2, 0.12, 1.0), base())
r = act(materialName="oxide red", color=[0.1, 0.2, 0.3, 0.5])
check("rgba recolours", base() == (0.1, 0.2, 0.3, 0.5) and len(cube.material_slots) == 1, base())
for bad in ([1, 2], [2, 0, 0], [float("nan"), 0, 0], "red"):
r = act(materialName="oxide red", color=bad)
check("bad colour refused %r" % (bad,), r["status"] == "failed" and "color" in r["message"], r["message"])
check("refusals left colour", base() == (0.1, 0.2, 0.3, 0.5), base())
r = act(materialName="never made", color=[7, 0, 0])
check("refusal creates no material", "never made" not in bpy.data.materials)
json.dump(R, open(out, "w"), indent=1)
sys.exit(0 if all(c["ok"] for c in R["checks"]) else 1)

View file

@ -1,147 +0,0 @@
"""assignMaterial tests. No Blender: a fake bpy with materials, slots and a Principled node.
Run: python3 -m unittest discover -s surfaces/blender/tests -v
The real API is exercised by tests/live/material_colour.py.
"""
from __future__ import annotations
import sys
import types
import unittest
from pathlib import Path
ADDON = Path(__file__).resolve().parents[1] / "brokkr_bridge"
package = types.ModuleType("brokkr_bridge")
package.__path__ = [str(ADDON)]
sys.modules.setdefault("brokkr_bridge", package)
from brokkr_bridge import blender_target as bt # noqa: E402
NS = types.SimpleNamespace
class Nodes(dict):
def new(self, idname):
node = NS(bl_idname=idname, inputs={"Base Color": NS(default_value=(0.8, 0.8, 0.8, 1.0))})
self["Principled BSDF"] = node
return node
class Material:
def __init__(self, name):
self.name, self.diffuse_color, self.node_tree = name, (0.8, 0.8, 0.8, 1.0), None
self._use_nodes = False
@property
def use_nodes(self):
return self._use_nodes
@use_nodes.setter
def use_nodes(self, value): # Blender creates the default tree, with a Principled BSDF, on first use
self._use_nodes = value
if value and self.node_tree is None:
self.node_tree = NS(nodes=Nodes())
self.node_tree.nodes.new("ShaderNodeBsdfPrincipled")
class Materials(dict):
def new(self, name):
self[name] = Material(name)
return self[name]
class FakeBpy:
def __init__(self):
self.mesh = NS(name="Cube", type="MESH", data=NS(materials=[]))
self.empty = NS(name="Empty", type="EMPTY", data=None)
self.data = NS(objects={"Cube": self.mesh, "Empty": self.empty}, materials=Materials())
class MaterialTests(unittest.TestCase):
def setUp(self):
self.bpy = FakeBpy()
self.target = bt.BrokkrBlenderTarget(self.bpy)
def assign(self, **fields):
return self.target.execute_command(NS(), {"commandId": "c1", "action": "assignMaterial",
"targetObjectName": "Cube", **fields})
def base_color(self, name="oxide red"):
return tuple(self.bpy.data.materials[name].node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value)
def test_rgb_colour_sets_base_color_and_diffuse_with_alpha_one(self):
receipt = self.assign(materialName="oxide red", color=[0.56, 0.2, 0.12])
self.assertEqual(receipt["status"], "accepted", receipt["message"])
material = self.bpy.data.materials["oxide red"]
self.assertEqual(self.base_color(), (0.56, 0.2, 0.12, 1.0))
self.assertEqual(tuple(material.diffuse_color), (0.56, 0.2, 0.12, 1.0))
self.assertTrue(material.use_nodes)
self.assertEqual(self.bpy.mesh.data.materials, [material])
def test_rgba_colour_keeps_its_alpha(self):
self.assign(materialName="glass", color=[0.1, 0.2, 0.3, 0.5])
self.assertEqual(self.base_color("glass"), (0.1, 0.2, 0.3, 0.5))
self.assertEqual(tuple(self.bpy.data.materials["glass"].diffuse_color), (0.1, 0.2, 0.3, 0.5))
def test_the_colour_limits_themselves_are_accepted(self):
self.assign(materialName="black", color=[0, 0, 0])
self.assertEqual(self.base_color("black"), (0.0, 0.0, 0.0, 1.0))
self.assign(materialName="white", color=[1, 1, 1, 1])
self.assertEqual(self.base_color("white"), (1.0, 1.0, 1.0, 1.0))
self.assign(materialName="clear", color=[1.0, 0.0, 0.5, 0])
self.assertEqual(self.base_color("clear"), (1.0, 0.0, 0.5, 0.0))
def test_an_explicit_null_colour_is_no_colour(self):
self.assertEqual(self.assign(materialName="plain", color=None)["status"], "accepted")
self.assertEqual(tuple(self.bpy.data.materials["plain"].diffuse_color), (0.8, 0.8, 0.8, 1.0))
def test_no_colour_leaves_a_new_material_at_blender_defaults(self):
self.assertEqual(self.assign(materialName="plain")["status"], "accepted")
self.assertEqual(tuple(self.bpy.data.materials["plain"].diffuse_color), (0.8, 0.8, 0.8, 1.0))
def test_malformed_colour_is_refused_naming_color_and_changes_nothing(self):
for bad in ([1], [1, 0], [0, 0, 0, 1, 1], "red", 0.5, {"r": 1}, ["a", 0, 0], [True, 0, 0], [None, 0, 0],
[float("nan"), 0, 0], [float("inf"), 0, 0], [-0.1, 0, 0], [1.1, 0, 0], [0, 0, 0, 2],
[1.0000001, 0, 0], [-1e-9, 0, 0], [0, 0, 0, 1.0000001], [10**400, 0, 0], [0, -10**400, 0]):
receipt = self.assign(materialName="oxide red", color=bad)
self.assertEqual(receipt["status"], "failed", bad)
self.assertIn("color", receipt["message"], bad)
self.assertEqual(dict(self.bpy.data.materials), {})
self.assertEqual(self.bpy.mesh.data.materials, [])
def test_assigning_twice_keeps_one_slot(self):
self.assign(materialName="oxide red", color=[0.5, 0.1, 0.1])
self.assign(materialName="oxide red", color=[0.5, 0.1, 0.1])
self.assertEqual(len(self.bpy.mesh.data.materials), 1)
self.assign(materialName="charcoal")
self.assertEqual([m.name for m in self.bpy.mesh.data.materials], ["oxide red", "charcoal"])
def test_reusing_a_material_recolours_only_when_colour_is_given(self):
self.assign(materialName="oxide red", color=[0.5, 0.1, 0.1])
self.assign(materialName="oxide red")
self.assertEqual(self.base_color(), (0.5, 0.1, 0.1, 1.0))
self.assign(materialName="oxide red", color=[0.9, 0.9, 0.9])
self.assertEqual(self.base_color(), (0.9, 0.9, 0.9, 1.0))
self.assertEqual(len(self.bpy.mesh.data.materials), 1)
def test_refused_colour_on_an_existing_material_leaves_it_alone(self):
self.assign(materialName="oxide red", color=[0.5, 0.1, 0.1])
self.assertEqual(self.assign(materialName="oxide red", color=[2, 0, 0])["status"], "failed")
self.assertEqual(self.base_color(), (0.5, 0.1, 0.1, 1.0))
def test_existing_tree_without_a_principled_node_gets_one(self):
material = self.bpy.data.materials.new("bare")
material.use_nodes = True
material.node_tree.nodes.clear()
self.assertEqual(self.assign(materialName="bare", color=[0.2, 0.3, 0.4])["status"], "accepted")
self.assertEqual(self.base_color("bare"), (0.2, 0.3, 0.4, 1.0))
def test_object_without_material_slots_is_refused(self):
receipt = self.target.execute_command(NS(), {"commandId": "c", "action": "assignMaterial",
"targetObjectName": "Empty", "color": [1, 0, 0]})
self.assertEqual(receipt["status"], "failed")
self.assertEqual(dict(self.bpy.data.materials), {})
if __name__ == "__main__":
unittest.main()

View file

@ -1,722 +0,0 @@
"""Node-graph action tests. No Blender: a fake bpy with just enough node-tree surface.
Run: python3 -m unittest discover -s surfaces/blender/tests -v
The real API is exercised by tests/live/gn_actions.py.
"""
from __future__ import annotations
import sys
import types
import unittest
import unittest.mock
from pathlib import Path
ADDON = Path(__file__).resolve().parents[1] / "brokkr_bridge"
package = types.ModuleType("brokkr_bridge")
package.__path__ = [str(ADDON)]
sys.modules.setdefault("brokkr_bridge", package)
from brokkr_bridge import blender_target as bt # noqa: E402
from brokkr_bridge import node_actions as na # noqa: E402
NS = types.SimpleNamespace
class Sock:
def __init__(self, identifier, name, kind="VALUE", default=0.0):
self.identifier, self.name, self.type, self.enabled = identifier, name, kind, True
self.default_value, self.is_linked = default, False
class Node(dict):
"""A node; ID properties are the dict part."""
counter = 1000
def as_pointer(self):
return self.pointer
PROPS = {"operation": NS(type="ENUM", is_readonly=False), "flag": NS(type="BOOLEAN", is_readonly=False),
"filepath": NS(type="STRING", is_readonly=False), "location": NS(type="FLOAT", is_readonly=False)}
def __init__(self, idname):
super().__init__()
self.bl_idname, self.location, self.label, self.operation, self.flag = idname, [0, 0], "", "ADD", False
Node.counter += 1
self.pointer = Node.counter
self.bl_rna = NS(properties=self.PROPS)
self.inputs = [Sock("A", "A"), Sock("Size", "Size", "VECTOR", (1.0, 1.0, 1.0)), Sock("B", "Dup"), Sock("B_VEC", "Dup")]
self.outputs = [Sock("Out", "Out")]
for socket in self.inputs + self.outputs:
socket.node = self
def __setattr__(self, key, value):
if key == "operation" and value not in ("ADD", "MULTIPLY"):
raise TypeError("enum not found")
object.__setattr__(self, key, value)
class Nodes(list):
def new(self, idname):
if idname.endswith("Missing"):
raise RuntimeError("Node type undefined")
node = Node(idname)
self.append(node)
return node
def remove(self, node):
list.remove(self, node)
class Proxy:
"""A fresh wrapper per access, as bpy hands out: equal by pointer, with a different id() each time."""
def __init__(self, node):
self._node = node
def as_pointer(self):
return self._node.pointer
def __getattr__(self, name):
return getattr(self._node, name)
def __eq__(self, other):
return getattr(other, "pointer", None) == self._node.pointer or (isinstance(other, Proxy) and other._node is self._node)
__hash__ = None
class Links(list):
def __init__(self, valid=True):
super().__init__()
self.valid = valid
def new(self, a, b):
for old in [l for l in self if l.to_socket is b]: # a single-link input loses its old link, as in Blender
list.remove(self, old)
link = NS(from_socket=a, to_socket=b, from_node=Proxy(a.node), to_node=Proxy(b.node), is_muted=False, is_valid=self.valid(a, b) if callable(self.valid) else self.valid)
self.append(link)
return link
def remove(self, link):
list.remove(self, link)
class Interface:
def __init__(self):
self.items_tree = []
def new_socket(self, name, in_out, socket_type):
item = NS(item_type="SOCKET", name=name, in_out=in_out, socket_type=socket_type, identifier=f"Socket_{len(self.items_tree)}", default_value=None)
self.items_tree.append(item)
return item
class Tree:
bl_idname = "GeometryNodeTree"
library = None
def __init__(self, name):
self.name, self.nodes, self.links, self.interface = name, Nodes(), Links(), Interface()
class Groups(dict):
def new(self, name, kind):
self[name] = Tree(name)
return self[name]
def remove(self, tree):
del self[tree.name]
class Inputs:
def __init__(self):
self.values = {}
def __getattr__(self, identifier):
return self.values.setdefault(identifier, NS(value=None))
class Modifiers(list):
refuses = False
def get(self, name):
return next((m for m in self if m.name == name), None)
def remove(self, modifier):
list.remove(self, modifier)
def new(self, name, kind):
if self.refuses:
return None # Blender returns None for object types without modifiers
m = NS(name=name, type=kind, node_group=None, properties=NS(inputs=Inputs()))
self.append(m)
return m
class Obj:
def __init__(self, name):
self.name, self.type, self.data, self.modifiers, self.tagged = name, "MESH", None, Modifiers(), 0
def update_tag(self):
self.tagged += 1
def get(self, key, default=None):
return default
def __setitem__(self, key, value):
self.custom = (key, value)
class FakeBpy:
def __init__(self):
self.data = NS(node_groups=Groups(), objects={"Host": Obj("Host")})
class NodeActionTests(unittest.TestCase):
def setUp(self):
self.bpy = FakeBpy()
self.target = bt.BrokkrBlenderTarget(self.bpy)
self.n = 0
def run_action(self, **command):
self.n += 1
command.setdefault("commandId", f"c{self.n}")
return self.target.execute_command(None, command)
def accepted(self, **command):
receipt = self.run_action(**command)
self.assertEqual(receipt["status"], "accepted", receipt["message"])
return receipt
def refused(self, **command):
receipt = self.run_action(**command)
self.assertEqual(receipt["status"], "failed", receipt["message"])
return receipt
def group(self, interface=None):
self.accepted(action="createNodeGroup", treeKey="G", interface=interface or [{"inOut": "OUTPUT", "name": "Geometry", "type": "Geometry"}, {"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}])
return self.bpy.data.node_groups["G"]
# createNodeGroup
def test_group_gets_interface_and_stamped_group_nodes(self):
receipt = self.accepted(action="createNodeGroup", treeKey="G", interface=[{"inOut": "INPUT", "name": "Count", "type": "Int", "default": 8}])
tree = self.bpy.data.node_groups["G"]
self.assertEqual([n.get("brokkr_id") for n in tree.nodes], ["gin", "gout"])
self.assertEqual(tree.interface.items_tree[0].default_value, 8)
self.assertIn("Count=Socket_0", receipt["message"])
def test_group_refusals_create_nothing(self):
for interface in (None, [], "x", [{"inOut": "SIDEWAYS", "name": "a", "type": "Int"}], [{"inOut": "INPUT", "name": "a", "type": "Mesh"}],
[{"inOut": "INPUT", "name": "a", "type": "Int"}, {"inOut": "INPUT", "name": "a", "type": "Float"}],
[{"inOut": "INPUT", "name": "a", "type": "Int", "default": 1.5}], [{"inOut": "INPUT", "name": "a", "type": "Vector", "default": [1, 2]}]):
self.refused(action="createNodeGroup", treeKey="G", interface=interface)
self.refused(action="createNodeGroup", treeKey="../x", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.refused(action="createNodeGroup", treeKind="material", treeKey="G", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertEqual(len(self.bpy.data.node_groups), 0)
def test_group_that_exists_is_not_overwritten(self):
self.group()
self.refused(action="createNodeGroup", treeKey="G", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertEqual(len(self.bpy.data.node_groups["G"].interface.items_tree), 2)
# addNode
def test_add_node_applies_properties_then_inputs_and_stamps_id(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="m", blIdname="ShaderNodeMath", location=[10, 20], properties={"operation": "MULTIPLY"}, inputs={"A": 3, "Size": [1, 2, 3]})
node = tree.nodes[-1]
self.assertEqual((node.operation, node.location, node.inputs[0].default_value, node.inputs[1].default_value), ("MULTIPLY", [10.0, 20.0], 3.0, (1.0, 2.0, 3.0)))
def test_add_node_refusals_leave_no_node(self):
tree = self.group()
before = len(tree.nodes)
cases = [dict(blIdname="GeometryNodeImportOBJ"), dict(blIdname="GeometryNodeBake"), dict(blIdname="NodeGroupInput"), dict(blIdname="bpy.ops"),
dict(blIdname="ShaderNodeMissing"), dict(blIdname="ShaderNodeMath", properties={"operation": "NOPE"}),
dict(blIdname="ShaderNodeMath", properties={"filepath": "/etc/passwd"}), dict(blIdname="ShaderNodeMath", properties={"location": 1.0}),
dict(blIdname="ShaderNodeMath", properties={"missing": 1}), dict(blIdname="ShaderNodeMath", inputs={"Dup": 1}),
dict(blIdname="ShaderNodeMath", inputs={"Nope": 1}), dict(blIdname="ShaderNodeMath", inputs={"A": float("nan")}),
dict(blIdname="ShaderNodeMath", inputs={"A": True}), dict(blIdname="ShaderNodeMath", inputs={"A": 1e12}),
dict(blIdname="ShaderNodeMath", inputs={"Size": [1, 2]}), dict(blIdname="ShaderNodeMath", location=[1])]
for case in cases:
self.refused(action="addNode", treeKey="G", id="x", **case)
self.assertEqual(len(tree.nodes), before)
def test_string_and_reserved_properties_are_refused_by_rule(self):
self.group()
for name in ("filepath", "location"):
receipt = self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", properties={name: "v"})
self.assertIn("no settable property", receipt["message"])
def test_add_node_refuses_bad_ids_duplicates_and_missing_tree(self):
self.group()
for bad in ("", "a b", "x" * 64, "../x", None):
self.refused(action="addNode", treeKey="G", id=bad, blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="gin", blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="Nope", id="a", blIdname="ShaderNodeMath")
def test_node_cap(self):
tree = self.group()
for i in range(na.MAX_NODES - 2):
tree.nodes.new("ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="over", blIdname="ShaderNodeMath")
def test_linked_library_group_is_read_only(self):
tree = self.group()
tree.library = NS(filepath="lib.blend")
self.refused(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
# access rules, pinned by literals
def test_each_denied_node_family_is_refused_alone(self):
tree = self.group()
denied = ["GeometryNodeImportOBJ", "GeometryNodeExportPLY", "ShaderNodeScript", "GeometryNodeBake", "GeometryNodeGroup",
"GeometryNodeSimulationInput", "GeometryNodeRepeatInput", "GeometryNodeForeachGeometryElementInput",
"GeometryNodeViewer", "ShaderNodeTexImage", "ShaderNodeTexEnvironment", "GeometryNodeImageTexture",
"GeometryNodeInputImage", "ShaderNodeTexIES"]
before = len(tree.nodes)
for idname in denied:
self.refused(action="addNode", treeKey="G", id="x", blIdname=idname)
self.assertEqual(len(tree.nodes), before)
def test_legitimate_node_families_are_allowed(self):
for i, idname in enumerate(["ShaderNodeMath", "GeometryNodeMeshGrid", "FunctionNodeCompare", "GeometryNodeSetPosition", "ShaderNodeTexNoise"]):
self.group() if i == 0 else None
self.accepted(action="addNode", treeKey="G", id=f"n{i}", blIdname=idname)
def test_idname_must_be_a_whole_known_prefixed_name(self):
self.group()
for bad in ("Foo", "ShaderNode", "xShaderNodeMath", "ShaderNodeMath\n", "ShaderNode Math", "ShaderNode_Math", "CompositorNodeMath", "ShaderNodeMath;", "", None, 5):
self.refused(action="addNode", treeKey="G", id="x", blIdname=bad)
def test_node_cap_is_exactly_500(self):
tree = self.group()
for _ in range(497):
tree.nodes.new("ShaderNodeMath")
self.assertEqual(len(tree.nodes), 499)
self.accepted(action="addNode", treeKey="G", id="last", blIdname="ShaderNodeMath")
self.assertEqual(len(tree.nodes), 500)
self.refused(action="addNode", treeKey="G", id="over", blIdname="ShaderNodeMath")
def test_interface_cap_is_exactly_64(self):
def sockets(count):
return [{"inOut": "INPUT", "name": f"s{i}", "type": "Int"} for i in range(count)]
self.accepted(action="createNodeGroup", treeKey="A", interface=sockets(64))
self.refused(action="createNodeGroup", treeKey="B", interface=sockets(65))
self.refused(action="createNodeGroup", treeKey="C", interface=[])
def test_int_limit_is_exactly_4096(self):
self.group()
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for value in (4096, -4096):
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=value)
for value in (4097, -4097):
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=value)
self.accepted(action="addNode", treeKey="G", id="ok", blIdname="ShaderNodeMath", inputs={"A": 4097}) # a float input has no int cap
def test_value_limit_is_exactly_one_billion(self):
self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath", inputs={"A": 1e9})
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath", inputs={"A": -1e9})
self.refused(action="addNode", treeKey="G", id="c", blIdname="ShaderNodeMath", inputs={"A": 1000000001})
self.refused(action="addNode", treeKey="G", id="d", blIdname="ShaderNodeMath", inputs={"Size": [0, 0, -1000000001]})
self.accepted(action="addNode", treeKey="G", id="e", blIdname="ShaderNodeMath", location=[1e9, -1e9])
self.refused(action="addNode", treeKey="G", id="f", blIdname="ShaderNodeMath", location=[1000000001, 0])
def test_modifier_cap_is_exactly_8(self):
self.group()
for i in range(8):
self.accepted(action="addModifier", objectName="Host", modifierName=f"m{i}", treeKey="G")
self.refused(action="addModifier", objectName="Host", modifierName="m8", treeKey="G")
self.accepted(action="addModifier", objectName="Host", modifierName="m3", treeKey="G") # reuse is not growth
def test_location_must_be_two_finite_numbers(self):
tree = self.group()
for bad in ([1, 2, 3], "ab", 5, [1, float("nan")], [1, "2"], [True, 1]):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", location=bad)
self.assertEqual(len(tree.nodes), 2)
def test_readonly_node_property_is_refused_and_untouched(self):
self.group()
with unittest.mock.patch.dict(Node.PROPS, {"flag": NS(type="BOOLEAN", is_readonly=True)}):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", properties={"flag": True})
self.assertEqual(len(self.bpy.data.node_groups["G"].nodes), 2)
def test_input_without_a_default_or_with_a_link_is_refused(self):
tree = self.group()
original = Nodes.new
def linked(nodes, idname):
node = original(nodes, idname)
node.inputs[0].is_linked = True
return node
with unittest.mock.patch.object(Nodes, "new", linked):
self.refused(action="addNode", treeKey="G", id="x", blIdname="ShaderNodeMath", inputs={"A": 1})
self.assertEqual(len(tree.nodes), 2)
def bare(nodes, idname):
node = original(nodes, idname)
del node.inputs[0].default_value
return node
with unittest.mock.patch.object(Nodes, "new", bare):
self.refused(action="addNode", treeKey="G", id="y", blIdname="ShaderNodeMath", inputs={"A": 1})
self.assertEqual(len(tree.nodes), 2)
def test_create_node_group_that_fails_midway_leaves_no_group(self):
original = Nodes.new
def failing(nodes, idname):
if idname == "NodeGroupOutput":
raise RuntimeError("boom")
return original(nodes, idname)
with unittest.mock.patch.object(Nodes, "new", failing):
self.refused(action="createNodeGroup", treeKey="Half", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.assertNotIn("Half", self.bpy.data.node_groups)
self.accepted(action="createNodeGroup", treeKey="Half", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
def test_add_modifier_will_not_take_over_a_foreign_modifier(self):
self.group()
host = self.bpy.data.objects["Host"]
foreign = host.modifiers.new("Mine", "SUBSURF")
self.refused(action="addModifier", objectName="Host", modifierName="Mine", treeKey="G")
self.assertIsNone(foreign.node_group)
self.assertEqual(len(host.modifiers), 1)
def test_set_modifier_input_only_drives_geometry_nodes_modifiers(self):
tree = self.group()
host = self.bpy.data.objects["Host"]
foreign = host.modifiers.new("Mine", "SUBSURF")
foreign.node_group = tree
self.refused(action="setModifierInput", objectName="Host", modifierName="Mine", socket="Count", value=1)
self.assertEqual(foreign.properties.inputs.values, {})
self.assertEqual(host.tagged, 0)
def test_blender_capabilities_include_everything_the_daemon_advertises_for_this_cut(self):
# brokkr-daemon/src/main.rs lists the same names (and asserts them in its own test); a capability
# added to one side only fails one of the two tests.
for name in ("asset.import.gltf", "camera.write", "light.write", "world.write", "render.still", "nodegraph.geometry.write", "geometry.evaluated.read"):
self.assertIn(name, bt.CAPABILITIES)
# linkSockets
def test_link_by_identifier_and_by_unique_name(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Size")
self.assertEqual(len(tree.links), 2)
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Dup") # name shared by two sockets
self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="zzz", toSocket="A")
self.assertEqual(len(tree.links), 2)
def test_unavailable_socket_is_not_addressable_by_name(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
tree.nodes[-1].inputs[3].enabled = False
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="Dup")
def test_invalid_link_is_refused_and_removed(self):
tree = self.group()
tree.links.valid = False
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
receipt = self.refused(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.assertIn("not valid", receipt["message"]) # past the cycle check: Blender was asked and said no
self.assertEqual(len(tree.links), 0)
# addModifier / setModifierInput
def test_modifier_assigns_group_and_reuses_by_name(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.assertEqual(len(host.modifiers), 1)
self.assertIs(host.modifiers[0].node_group, self.bpy.data.node_groups["G"])
self.refused(action="addModifier", objectName="Host", modifierType="SUBSURF", treeKey="G")
self.refused(action="addModifier", objectName="Nope", treeKey="G")
self.refused(action="addModifier", objectName="Host", treeKey="Nope")
def test_modifier_cap(self):
self.group()
for i in range(na.MAX_MODIFIERS):
self.accepted(action="addModifier", objectName="Host", modifierName=f"m{i}", treeKey="G")
self.refused(action="addModifier", objectName="Host", modifierName="extra", treeKey="G")
def test_set_modifier_input_writes_value_through_the_struct_and_tags(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
tags = host.tagged
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Count", value=12)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 12)
self.assertEqual(host.tagged, tags + 1)
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Socket_1", value=3)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 3)
def test_set_modifier_input_refusals_change_nothing(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for socket, value in (("Count", 1.5), ("Count", "x"), ("Count", True), ("Count", None), ("Count", 1e12), ("Nope", 1), ("Geometry", 1), ("", 1)):
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket=socket, value=value)
self.refused(action="setModifierInput", objectName="Host", modifierName="other", socket="Count", value=1)
self.assertEqual(host.modifiers[0].properties.inputs.values, {})
def test_set_modifier_input_by_name_needs_a_unique_name(self):
tree = self.group([{"inOut": "INPUT", "name": "Dup", "type": "Int"}])
tree.interface.new_socket("Dup", in_out="INPUT", socket_type="NodeSocketFloat") # a hand-made group can repeat a name
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.refused(action="setModifierInput", objectName="Host", modifierName="GN", socket="Dup", value=1)
self.accepted(action="setModifierInput", objectName="Host", modifierName="GN", socket="Socket_1", value=1.5)
self.assertEqual(host.modifiers[0].properties.inputs.Socket_1.value, 1.5)
# removal, unlinking, cycles
def chain(self):
tree = self.group()
for node_id in ("a", "b", "c"):
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
return tree
def test_cycles_and_self_links_are_refused_and_change_nothing(self):
tree = self.chain()
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="b", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
before = list(tree.links)
for src, dst in (("c", "a"), ("b", "a"), ("a", "a"), ("c", "b")):
receipt = self.refused(action="linkSockets", treeKey="G", fromNode=src, fromSocket="Out", toNode=dst, toSocket="Size")
self.assertIn("cycle", receipt["message"])
self.assertEqual(list(tree.links), before)
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="Size") # a forward skip is no cycle
def test_cycle_detection_survives_fresh_node_wrappers_on_long_chains(self):
tree = self.group()
ids = [f"n{i}" for i in range(12)]
for node_id in ids:
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for a, b in zip(ids, ids[1:]):
self.accepted(action="linkSockets", treeKey="G", fromNode=a, fromSocket="Out", toNode=b, toSocket="A")
# a diamond ladder: every rung reachable two ways
for a, b in zip(ids[:-2], ids[2:]):
self.accepted(action="linkSockets", treeKey="G", fromNode=a, fromSocket="Out", toNode=b, toSocket="Size")
count = len(tree.links)
for head in ids[:-1]:
self.refused(action="linkSockets", treeKey="G", fromNode=ids[-1], fromSocket="Out", toNode=head, toSocket="Size")
self.assertEqual(len(tree.links), count)
def test_cycle_through_a_later_branch_is_found(self):
tree = self.group()
for node_id in "abcd":
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for src, dst in (("a", "b"), ("a", "c"), ("c", "d")):
self.accepted(action="linkSockets", treeKey="G", fromNode=src, fromSocket="Out", toNode=dst, toSocket="A")
self.refused(action="linkSockets", treeKey="G", fromNode="d", fromSocket="Out", toNode="a", toSocket="A")
def test_cycle_walk_visits_each_node_once_on_a_ladder(self):
tree = self.group()
ids = [f"n{i}" for i in range(40)]
for node_id in ids:
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
for a, b in zip(ids, ids[1:]):
tree.links.new(tree.nodes[2 + ids.index(a)].outputs[0], tree.nodes[2 + ids.index(b)].inputs[0])
for a, b in zip(ids, ids[2:]):
tree.links.new(tree.nodes[2 + ids.index(a)].outputs[0], tree.nodes[2 + ids.index(b)].inputs[1])
calls = []
real = na.node_key
with unittest.mock.patch.object(na, "node_key", lambda n: calls.append(1) or real(n)):
self.assertFalse(na.reaches(tree, tree.nodes[2], NS(pointer=-1, as_pointer=lambda: -1)))
self.assertLess(len(calls), 4 * len(tree.links))
def test_remove_node(self):
tree = self.chain()
self.accepted(action="removeNode", treeKey="G", id="b")
self.assertEqual([n.get("brokkr_id") for n in tree.nodes], ["gin", "gout", "a", "c"])
again = self.accepted(action="removeNode", treeKey="G", id="b")
self.assertIn("already absent", again["message"])
for keep in ("gin", "gout"):
self.refused(action="removeNode", treeKey="G", id=keep)
self.refused(action="removeNode", treeKey="Nope", id="a")
self.refused(action="removeNode", treeKey="G", id="a b")
def test_unlink_removes_only_the_matching_links(self):
tree = self.chain()
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
self.accepted(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="Size")
self.refused(action="unlinkSockets", treeKey="G", fromNode="zzz", fromSocket="Out", toNode="c", toSocket="A")
self.assertIn("Removed 0", self.accepted(action="unlinkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")["message"])
self.assertEqual(len(tree.links), 2)
self.assertIn("Removed 1", self.accepted(action="unlinkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")["message"])
self.assertEqual(len(tree.links), 1)
self.accepted(action="unlinkSockets", treeKey="G", toNode="c", toSocket="Size")
self.assertEqual(len(tree.links), 0)
self.refused(action="unlinkSockets", treeKey="G", fromNode="a", toNode="c", toSocket="A") # a source node needs its socket
def test_remove_modifier_takes_only_geometry_nodes_modifiers(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
host.modifiers.new("Sub", "SUBSURF")
tags = host.tagged
self.refused(action="removeModifier", objectName="Host", modifierName="Sub")
self.assertEqual([m.name for m in host.modifiers], ["GN", "Sub"])
self.accepted(action="removeModifier", objectName="Host", modifierName="GN")
self.assertEqual([m.name for m in host.modifiers], ["Sub"])
self.assertEqual(host.tagged, tags + 1)
self.assertIn("already absent", self.accepted(action="removeModifier", objectName="Host", modifierName="GN")["message"])
self.refused(action="removeModifier", objectName="Nope", modifierName="GN")
for bad in (["x"], "x" * 256, "a\0b", 5):
self.refused(action="removeModifier", objectName="Host", modifierName=bad)
host.modifiers.new("Gé x/y", "NODES") # made by hand: any name Blender allows can be removed
self.accepted(action="removeModifier", objectName="Host", modifierName="Gé x/y")
self.assertEqual([m.name for m in host.modifiers], ["Sub"])
# setObjectCustomProperty on a Geometry Nodes host
def test_custom_property_naming_a_modifier_input_identifier_is_refused(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
for name in ("Socket_1", "customProperties.Socket_1"):
receipt = self.refused(action="setObjectCustomProperty", targetObjectName="Host", propertyName=name, value=3)
self.assertIn("setModifierInput", receipt["message"])
self.assertFalse(hasattr(host, "custom"))
def test_custom_property_sharing_only_a_display_name_is_an_ordinary_property(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.accepted(action="setObjectCustomProperty", targetObjectName="Host", propertyName="Count", value=3)
self.assertEqual(host.custom, ("Count", 3))
def test_other_custom_properties_on_the_same_object_still_work(self):
self.group()
host = self.bpy.data.objects["Host"]
self.accepted(action="addModifier", objectName="Host", modifierName="GN", treeKey="G")
self.accepted(action="setObjectCustomProperty", targetObjectName="Host", propertyName="tag", value=3)
self.assertEqual(host.custom, ("tag", 3))
# review fixes
def test_refused_link_restores_the_link_it_displaced(self):
tree = self.group()
self.accepted(action="addNode", treeKey="G", id="a", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="b", blIdname="ShaderNodeMath")
self.accepted(action="addNode", treeKey="G", id="c", blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
tree.links.valid = lambda a, b: a is not tree.nodes[3].outputs[0] # b.Out -> anything is invalid
self.refused(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
self.assertEqual([(l.from_socket, l.to_socket) for l in tree.links], [(tree.nodes[2].outputs[0], tree.nodes[4].inputs[0])])
def test_refused_link_restores_the_displaced_link_muted_state(self):
tree = self.group()
for node_id in ("a", "b", "c"):
self.accepted(action="addNode", treeKey="G", id=node_id, blIdname="ShaderNodeMath")
self.accepted(action="linkSockets", treeKey="G", fromNode="a", fromSocket="Out", toNode="c", toSocket="A")
tree.links[0].is_muted = True
tree.links.valid = lambda a, b: False
self.refused(action="linkSockets", treeKey="G", fromNode="b", fromSocket="Out", toNode="c", toSocket="A")
self.assertEqual([l.is_muted for l in tree.links], [True])
def test_ints_are_capped_and_patterns_reject_trailing_newlines(self):
self.group()
self.accepted(action="addNode", treeKey="G", id="ok", blIdname="ShaderNodeMath")
self.refused(action="addNode", treeKey="G", id="a\n", blIdname="ShaderNodeMath")
self.refused(action="createNodeGroup", treeKey="T\n", interface=[{"inOut": "INPUT", "name": "a", "type": "Int"}])
self.refused(action="createNodeGroup", treeKey="H", interface=[{"inOut": "INPUT", "name": "a", "type": "Int", "default": na.MAX_INT + 1}])
self.accepted(action="createNodeGroup", treeKey="H", interface=[{"inOut": "INPUT", "name": "a", "type": "Int", "default": na.MAX_INT}])
def test_modifier_on_an_object_type_without_modifiers_is_a_clear_refusal(self):
self.group()
host = self.bpy.data.objects["Host"]
host.modifiers.refuses = True
host.type = "CAMERA"
receipt = self.refused(action="addModifier", objectName="Host", treeKey="G")
self.assertIn("CAMERA", receipt["message"])
# snapshot
def snapshot_fixture(self, coords, instances):
host = self.bpy.data.objects["Host"]
host.modifiers.new("GN", "NODES")
class Verts(list):
def foreach_get(self_inner, name, buffer):
flat = [c for v in self_inner for c in v.co]
buffer[:] = type(buffer)("f", flat)
mesh = NS(vertices=Verts(NS(co=c) for c in coords), edges=[1], polygons=[])
evaluated = NS(to_mesh=lambda: mesh, to_mesh_clear=lambda: None)
host.evaluated_get = lambda dg: evaluated
self.bpy.context = NS(evaluated_depsgraph_get=lambda: NS(update=lambda: None, object_instances=instances))
return host
@staticmethod
def instance(parent, point, is_instance=True):
return NS(is_instance=is_instance, parent=NS(original=parent) if parent else None, matrix_world=NS(translation=point))
def evaluated(self, host):
return self.target._evaluated_snapshot(host, self.target._instance_scan())
def test_evaluated_snapshot_reports_counts_separate_bounds_and_cap(self):
host = self.bpy.data.objects["Host"]
n = bt.EVALUATED_POSITION_CAP + 2
other = Obj("other")
instances = [self.instance(host, (0.0, 5.0 + i, 0.0)) for i in range(n)] + [self.instance(other, (9.0, 9.0, 9.0)), self.instance(None, (8.0, 8.0, 8.0), False)]
host = self.snapshot_fixture([(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (2.0, 0.0, 0.0)], instances)
data = self.evaluated(host)
self.assertEqual((data["vertexCount"], data["edgeCount"], data["instanceCount"]), (3, 1, n))
self.assertEqual((data["vertexBoundsMin"], data["vertexBoundsMax"]), ([0.0, 0.0, 0.0], [2.0, 0.0, 0.0]))
self.assertEqual((data["instanceBoundsMin"], data["instanceBoundsMax"]), ([0.0, 5.0, 0.0], [0.0, 5.0 + n - 1, 0.0]))
self.assertEqual((len(data["instanceLocations"]), len(data["vertexLocations"]), data["truncated"]), (bt.EVALUATED_POSITION_CAP, 3, True))
self.assertFalse(data["nonFinite"])
def test_non_finite_positions_are_flagged_not_published(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0), (float("inf"), 0.0, 0.0)], [])
data = self.evaluated(host)
self.assertTrue(data["nonFinite"])
self.assertEqual((data["vertexLocations"], data["vertexBoundsMin"], data["vertexBoundsMax"]), ([], [], []))
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [self.instance(host, (float("nan"), 0.0, 0.0))])
data = self.evaluated(host)
self.assertTrue(data["nonFinite"] and data["instanceLocations"] == [] and data["instanceBoundsMin"] == [])
def test_a_huge_evaluated_mesh_reports_counts_only(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0)] * 3, [])
with unittest.mock.patch.object(bt, "EVALUATED_VERTEX_LIMIT", 2):
data = self.evaluated(host)
self.assertEqual((data["vertexCount"], data["positionsSkipped"], data["vertexLocations"], data["vertexBoundsMin"]), (3, True, [], []))
def test_per_object_snapshots_reuse_the_one_scan(self):
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [])
calls = []
get = self.bpy.context.evaluated_depsgraph_get
self.bpy.context.evaluated_depsgraph_get = lambda: calls.append(1) or get()
scan = self.target._instance_scan()
for _ in range(3):
self.target._evaluated_snapshot(host, scan)
self.assertEqual(len(calls), 1)
def test_instance_scan_stops_at_the_limit_and_says_so(self):
host = self.bpy.data.objects["Host"]
visited = []
def many():
for i in range(50):
visited.append(i)
yield self.instance(host, (float(i), 0.0, 0.0))
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], many())
with unittest.mock.patch.object(bt, "EVALUATED_INSTANCE_SCAN_LIMIT", 10):
scan = self.target._instance_scan()
data = self.target._evaluated_snapshot(host, scan)
self.assertEqual((data["instanceCount"], data["instancesComplete"]), (10, False))
other = Obj("Other")
other.modifiers.new("GN", "NODES")
other.evaluated_get = host.evaluated_get
self.assertIsNone(self.target._evaluated_snapshot(other, scan)["instanceCount"]) # never reached: unknown, not 0
self.assertLessEqual(len(visited), 11) # the generator is abandoned, not drained
host = self.snapshot_fixture([(0.0, 0.0, 0.0)], [self.instance(host, (1.0, 0.0, 0.0))])
self.assertTrue(self.evaluated(host)["instancesComplete"])
def test_object_without_modifiers_has_no_evaluated_block(self):
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], None))
self.assertIsNone(self.target._evaluated_snapshot(self.bpy.data.objects["Host"], (None, {}, True)))
if __name__ == "__main__":
unittest.main()

View file

@ -86,27 +86,7 @@ class FakeBpy:
worlds=NS(get=lambda name: None, new=self.new_world), worlds=NS(get=lambda name: None, new=self.new_world),
) )
self.path = NS(abspath=lambda p: p) self.path = NS(abspath=lambda p: p)
self.created = [] self.ops = NS(import_scene=NS(gltf=self.import_gltf), render=NS(render=self.render_still))
self.app = NS(background=False)
self.exported = []
self.export_result = {"FINISHED"}
self.export_writes = True
for o in self.data.objects.values():
o.selected = o is self.mesh
o.select_get = (lambda o=o: o.selected)
o.select_set = (lambda state, o=o: setattr(o, "selected", state))
add = lambda kind: (lambda **kw: self.add_object(kind, kw))
self.ops = NS(import_scene=NS(gltf=self.import_gltf), render=NS(render=self.render_still),
export_scene=NS(gltf=self.export_gltf),
mesh=NS(primitive_cube_add=add("MESH")), object=NS(light_add=add("LIGHT"),
camera_add=add("CAMERA"), empty_add=add("EMPTY")))
self.on_add = lambda obj: None
def add_object(self, kind, kwargs):
self.created.append((kind, kwargs))
obj = NS(name=kind.title(), type=kind)
self.data.objects[obj.name] = obj
self.on_add(obj)
def new_world(self, name): def new_world(self, name):
background = NS(inputs={"Color": NS(default_value=None), "Strength": NS(default_value=None)}) background = NS(inputs={"Color": NS(default_value=None), "Strength": NS(default_value=None)})
@ -119,13 +99,6 @@ class FakeBpy:
self.imported.append(filepath) self.imported.append(filepath)
self.data.objects["Imported"] = NS(name="Imported", type="MESH", data=None) self.data.objects["Imported"] = NS(name="Imported", type="MESH", data=None)
def export_gltf(self, **kwargs):
kwargs["selected_at_call"] = sorted(o.name for o in self.data.objects.values() if o.selected)
self.exported.append(kwargs)
if self.export_writes:
Path(kwargs["filepath"]).write_bytes(b"x" * 10)
return self.export_result
def render_still(self, write_still): def render_still(self, write_still):
r = self.scene.render r = self.scene.render
self.rendered.append((r.engine, r.resolution_x, r.resolution_y, r.filepath, self.rendered.append((r.engine, r.resolution_x, r.resolution_y, r.filepath,
@ -144,9 +117,7 @@ class ActionTests(unittest.TestCase):
self.bpy = FakeBpy() self.bpy = FakeBpy()
self.target = bt.BrokkrBlenderTarget(self.bpy) self.target = bt.BrokkrBlenderTarget(self.bpy)
self.target.configure_action_dirs(str(self.root / "in"), str(self.root / "out")) self.target.configure_action_dirs(str(self.root / "in"), str(self.root / "out"))
self.context = NS(scene=self.bpy.scene, object=None, self.context = NS(scene=self.bpy.scene)
view_layer=NS(objects=Objects(dict(self.bpy.data.objects.items()))))
self.bpy.on_add = lambda obj: setattr(self.context, "object", obj)
def run_action(self, **command): def run_action(self, **command):
command.setdefault("commandId", "c1") command.setdefault("commandId", "c1")
@ -235,118 +206,6 @@ class ActionTests(unittest.TestCase):
self.assertEqual(self.run_action(action="setWorld", color=[9, 0, 0])["status"], "failed") self.assertEqual(self.run_action(action="setWorld", color=[9, 0, 0])["status"], "failed")
self.assertIsNone(self.bpy.scene.world) self.assertIsNone(self.bpy.scene.world)
# exportGltf
def test_export_glb_defaults(self):
receipt = self.run_action(action="exportGltf", filepath="ship/a.glb")
self.assertEqual(receipt["status"], "accepted", receipt)
path = str(self.root / "out" / "ship" / "a.glb")
self.assertIn(path, receipt["message"])
self.assertIn("10 bytes", receipt["message"])
call = self.bpy.exported[0]
self.assertEqual({k: v for k, v in call.items() if k != "selected_at_call"},
{"filepath": path, "export_format": "GLB", "export_extras": True,
"export_yup": True, "export_apply": True, "use_selection": False})
def test_export_gltf_suffix_is_separate_and_options_pass_through(self):
receipt = self.run_action(action="exportGltf", filepath="a.GLTF", extras=False, yUp=False,
applyModifiers=False)
self.assertEqual(receipt["status"], "accepted", receipt)
call = self.bpy.exported[0]
self.assertEqual((call["export_format"], call["export_extras"], call["export_yup"], call["export_apply"]),
("GLTF_SEPARATE", False, False, False))
def test_export_selection_selects_exactly_those_then_restores(self):
receipt = self.run_action(action="exportGltf", filepath="a.glb", selection=["Cam", "Lamp"])
self.assertEqual(receipt["status"], "accepted", receipt)
call = self.bpy.exported[0]
self.assertTrue(call["use_selection"])
self.assertEqual(call["selected_at_call"], ["Cam", "Lamp"])
self.assertEqual([o.name for o in self.bpy.data.objects.values() if o.selected], ["Cube"])
def test_export_without_a_selection_never_touches_selection(self):
def explode(*a, **k):
raise AssertionError("selection touched")
for o in self.bpy.data.objects.values():
o.select_set = explode
o.select_get = explode
receipt = self.run_action(action="exportGltf", filepath="a.glb")
self.assertEqual(receipt["status"], "accepted", receipt)
self.assertFalse(self.bpy.exported[0]["use_selection"])
def test_export_selection_ignores_objects_outside_the_view_layer_and_refuses_naming_one(self):
def not_in_layer(state):
raise RuntimeError("Object can't be selected because it is not in View Layer")
other = NS(name="Other", type="MESH", data=None, selected=False, select_get=lambda: False, select_set=not_in_layer)
self.bpy.data.objects["Other"] = other
ok = self.run_action(action="exportGltf", filepath="a.glb", selection=["Cam"])
self.assertEqual(ok["status"], "accepted", ok)
self.assertEqual(self.bpy.exported[0]["selected_at_call"], ["Cam"])
refused = self.run_action(action="exportGltf", filepath="b.glb", selection=["Cam", "Other"])
self.assertEqual(refused["status"], "failed")
self.assertIn("Other", refused["message"])
self.assertIn("view layer", refused["message"])
self.assertEqual(len(self.bpy.exported), 1)
self.assertFalse((self.root / "out" / "b.glb").exists())
def test_export_selection_restored_when_the_exporter_raises(self):
def boom(**kw):
raise RuntimeError("exporter blew up")
self.bpy.ops.export_scene.gltf = boom
receipt = self.run_action(action="exportGltf", filepath="a.glb", selection=["Cam"])
self.assertEqual(receipt["status"], "failed")
self.assertIn("exporter blew up", receipt["message"])
self.assertEqual([o.name for o in self.bpy.data.objects.values() if o.selected], ["Cube"])
def test_export_refusals_never_reach_bpy_or_disk(self):
(self.root / "out" / "taken.glb").write_bytes(b"old")
cases = [
{"filepath": "a.obj"}, {"filepath": "a"}, {"filepath": ""}, {"filepath": 5},
{"filepath": "../a.glb"}, {"filepath": str(self.root / "a.glb")},
{"filepath": "taken.glb"},
{"filepath": "a.glb", "selection": ["Nope"]},
{"filepath": "a.glb", "selection": ["Cube", "Nope"]},
{"filepath": "a.glb", "selection": []},
{"filepath": "a.glb", "selection": "Cube"},
{"filepath": "a.glb", "selection": [3]},
{"filepath": "a.glb", "extras": "yes"},
{"filepath": "a.glb", "yUp": 1},
{"filepath": "a.glb", "applyModifiers": None},
]
for case in cases:
self.assertEqual(self.run_action(action="exportGltf", **case)["status"], "failed", case)
self.assertEqual(self.bpy.exported, [])
self.assertEqual((self.root / "out" / "taken.glb").read_bytes(), b"old")
self.assertEqual(sorted(p.name for p in (self.root / "out").iterdir()), ["taken.glb"])
self.assertIn("already exists", self.run_action(action="exportGltf", filepath="taken.glb")["message"])
def test_export_gltf_refuses_an_existing_bin_sibling(self):
(self.root / "out" / "a.bin").write_bytes(b"old")
self.assertEqual(self.run_action(action="exportGltf", filepath="a.gltf")["status"], "failed")
self.assertEqual(self.bpy.exported, [])
def test_export_refuses_a_symlinked_folder(self):
os.symlink(self.root, self.root / "out" / "link")
self.assertEqual(self.run_action(action="exportGltf", filepath="link/a.glb")["status"], "failed")
self.assertEqual(self.bpy.exported, [])
def test_export_disabled_without_output_dir(self):
self.target.configure_action_dirs(str(self.root / "in"), "")
self.assertEqual(self.run_action(action="exportGltf", filepath="a.glb")["status"], "failed")
def test_export_cancelled_or_no_file_is_failed(self):
self.bpy.export_result = {"CANCELLED"}
self.assertEqual(self.run_action(action="exportGltf", filepath="a.glb")["status"], "failed")
self.bpy.export_result, self.bpy.export_writes = {"FINISHED"}, False
receipt = self.run_action(action="exportGltf", filepath="b.glb")
self.assertEqual((receipt["status"], receipt["message"]), ("failed", "glTF export wrote no file."))
def test_export_names_the_missing_selection_object(self):
receipt = self.run_action(action="exportGltf", filepath="a.glb", selection=["Cube", "Nope"])
self.assertEqual(receipt["message"], "Selection object was not found: Nope")
def test_export_is_advertised(self):
self.assertIn("asset.export.gltf", bt.CAPABILITIES)
# renderStill # renderStill
def test_render_still_writes_inside_output_dir_and_restores_settings(self): def test_render_still_writes_inside_output_dir_and_restores_settings(self):
receipt = self.run_action(action="renderStill", outputPath="shots/a.png", engine="cycles", receipt = self.run_action(action="renderStill", outputPath="shots/a.png", engine="cycles",
@ -379,78 +238,6 @@ class ActionTests(unittest.TestCase):
self.target.configure_action_dirs(str(self.root / "in"), "") self.target.configure_action_dirs(str(self.root / "in"), "")
self.assertEqual(self.run_action(action="renderStill", outputPath="a.png")["status"], "failed") self.assertEqual(self.run_action(action="renderStill", outputPath="a.png")["status"], "failed")
# createObject
def test_create_object_makes_the_four_admitted_types(self):
for kind in ("MESH", "LIGHT", "CAMERA", "EMPTY"):
receipt = self.run_action(action="createObject", objectType=kind, name="N")
self.assertEqual(receipt["status"], "accepted", kind)
self.assertEqual(self.bpy.created[-1][0], kind)
self.assertEqual(self.run_action(action="createObject", objectType="MESH", meshType="cube")["status"], "accepted")
def test_create_object_refuses_other_types_naming_the_type_and_the_allowed_set(self):
for kind in ("TEXT", "CURVE", "mesh", "", None, 3):
receipt = self.run_action(action="createObject", objectType=kind)
self.assertEqual(receipt["status"], "failed", kind)
for allowed in ("MESH", "LIGHT", "CAMERA", "EMPTY"):
self.assertIn(allowed, receipt["message"])
self.assertIn(repr(kind) if not isinstance(kind, str) else kind, receipt["message"])
self.assertEqual(self.bpy.created, [])
def test_create_object_refuses_a_mesh_type_other_than_cube(self):
for mesh_type in ("CYLINDER", "", 1, " CUBE", "CUBE "):
receipt = self.run_action(action="createObject", objectType="MESH", meshType=mesh_type)
self.assertEqual(receipt["status"], "failed", mesh_type)
self.assertIn("CUBE", receipt["message"])
self.assertEqual(self.bpy.created, [])
# renderStill: GPU engines without a GL runtime
def test_render_still_refuses_gpu_engines_headless_when_the_gl_library_is_missing(self):
self.bpy.app.background = True
for engine in ("BLENDER_WORKBENCH", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT", "blender_workbench"):
self.bpy.scene.render.ENGINES = FakeRender.ENGINES + ("BLENDER_EEVEE_NEXT",)
with mock.patch.object(bt, "missing_gl_library", return_value="libEGL.so.1"):
receipt = self.run_action(action="renderStill", outputPath="a.png", engine=engine)
self.assertEqual(receipt["status"], "failed", engine)
self.assertIn("libEGL.so.1", receipt["message"])
self.assertIn("CYCLES", receipt["message"])
self.assertEqual(self.bpy.rendered, [])
self.assertEqual(self.bpy.scene.render.engine, "BLENDER_EEVEE")
def test_render_still_refuses_the_scene_engine_too_and_leaves_no_folder(self):
self.bpy.app.background = True # scene engine is BLENDER_EEVEE
with mock.patch.object(bt, "missing_gl_library", return_value="libEGL.so.1"):
receipt = self.run_action(action="renderStill", outputPath="shots/a.png")
self.assertEqual(receipt["status"], "failed")
self.assertFalse((self.root / "out" / "shots").exists())
def test_render_still_gl_check_spares_cycles_foreground_and_a_present_library(self):
probe = mock.Mock(return_value="libEGL.so.1")
with mock.patch.object(bt, "missing_gl_library", probe):
self.bpy.app.background = True
self.assertEqual(self.run_action(action="renderStill", outputPath="a.png", engine="CYCLES")["status"], "accepted")
probe.assert_not_called()
self.bpy.app.background = False
self.assertEqual(self.run_action(action="renderStill", outputPath="b.png", engine="BLENDER_WORKBENCH")["status"], "accepted")
probe.assert_not_called()
self.bpy.app.background = True
probe.return_value = None
self.assertEqual(self.run_action(action="renderStill", outputPath="c.png", engine="BLENDER_WORKBENCH")["status"], "accepted")
def test_missing_gl_library_probes_only_on_linux_and_only_once(self):
bt.missing_gl_library.cache_clear()
with mock.patch.object(bt.sys, "platform", "win32"), mock.patch.object(bt.ctypes, "CDLL", side_effect=OSError) as cdll:
self.assertIsNone(bt.missing_gl_library())
cdll.assert_not_called()
bt.missing_gl_library.cache_clear()
with mock.patch.object(bt.sys, "platform", "linux"), mock.patch.object(bt.ctypes, "CDLL", side_effect=OSError) as cdll:
self.assertEqual(bt.missing_gl_library(), "libEGL.so.1")
self.assertEqual(bt.missing_gl_library(), "libEGL.so.1")
cdll.assert_called_once_with("libEGL.so.1")
bt.missing_gl_library.cache_clear()
with mock.patch.object(bt.sys, "platform", "linux"), mock.patch.object(bt.ctypes, "CDLL"):
self.assertIsNone(bt.missing_gl_library())
bt.missing_gl_library.cache_clear()
# importGltf: names # importGltf: names
def test_import_refuses_drive_unc_extended_stream_and_device_names(self): def test_import_refuses_drive_unc_extended_stream_and_device_names(self):
for name in ("C:foo.glb", "C:\\x\\model.glb", "\\\\host\\share\\model.glb", "\\\\?\\C:\\model.glb", for name in ("C:foo.glb", "C:\\x\\model.glb", "\\\\host\\share\\model.glb", "\\\\?\\C:\\model.glb",