Blender add-on: createObject and renderStill refuse what they cannot do #8

Merged
libby merged 4 commits from libby/blender-refusals into main 2026-11-10 23:14:06 +00:00
3 changed files with 136 additions and 4 deletions

View file

@ -1,5 +1,7 @@
from __future__ import annotations
import ctypes
import functools
import json
import math
import os
@ -34,6 +36,13 @@ SYNC_VAR_SCHEMA = "brokkr.sync.var.v0"
SYNC_TIMELINE_BINDING_SCHEMA = "brokkr.sync.timeline_binding.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
@ -521,6 +530,14 @@ class BrokkrBlenderTarget:
def _create_object(self, context: Any, command: dict[str, Any]) -> dict[str, Any]:
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()}"
location = _vector(command.get("location"), 3, (0.0, 0.0, 0.0))
@ -530,7 +547,7 @@ class BrokkrBlenderTarget:
self.bpy.ops.object.light_add(type=command.get("lightType", "POINT"), location=location)
elif object_type == "CAMERA":
self.bpy.ops.object.camera_add(location=location)
else:
else: # EMPTY
self.bpy.ops.object.empty_add(type=command.get("emptyDisplayType", "PLAIN_AXES"), location=location)
obj = context.object
@ -809,6 +826,12 @@ class BrokkrBlenderTarget:
render.engine = engine
except TypeError:
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:
render.resolution_x = width
if height is not None:
@ -1091,6 +1114,19 @@ def _load_cultmesh(cultlib_py_src: str) -> tuple[Any, Any]:
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:
return datetime.now(timezone.utc).isoformat()

View file

@ -117,6 +117,11 @@ Admitted command actions:
Scene action fields (all other keys are ignored; a refused intent changes nothing and
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
`Import Directory` (absolute paths, `..` and symlinks that resolve outside it are
refused). Empty `Import Directory` disables the action. Needs Blender's glTF add-on.
@ -153,7 +158,12 @@ gets a `failed` receipt whose message names the rule):
`engine` (any engine of the running Blender, case-insensitive), `resolutionX`,
`resolutionY` (whole numbers, 1 to 16384), `cameraName`. Engine, resolution,
camera, file path and format are restored after the render. The render blocks
Blender's UI until it finishes.
Blender's UI until it finishes. In a background (`-b`) Blender on Linux, the GL
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
`surfaces/blender/tests/live/headless_smoke.py` (usage in its docstring). One real

View file

@ -86,6 +86,8 @@ class FakeBpy:
worlds=NS(get=lambda name: None, new=self.new_world),
)
self.path = NS(abspath=lambda p: p)
self.created = []
self.app = NS(background=False)
self.exported = []
self.export_result = {"FINISHED"}
self.export_writes = True
@ -93,8 +95,18 @@ class FakeBpy:
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))
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):
background = NS(inputs={"Color": NS(default_value=None), "Strength": NS(default_value=None)})
@ -132,7 +144,9 @@ class ActionTests(unittest.TestCase):
self.bpy = FakeBpy()
self.target = bt.BrokkrBlenderTarget(self.bpy)
self.target.configure_action_dirs(str(self.root / "in"), str(self.root / "out"))
self.context = NS(scene=self.bpy.scene, view_layer=NS(objects=Objects(dict(self.bpy.data.objects.items()))))
self.context = NS(scene=self.bpy.scene, object=None,
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):
command.setdefault("commandId", "c1")
@ -365,6 +379,78 @@ class ActionTests(unittest.TestCase):
self.target.configure_action_dirs(str(self.root / "in"), "")
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
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",