From ece40040f46fb3e4221725861d855f4210320f0a Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 14:31:06 +0000 Subject: [PATCH 1/3] blender: createObject refuses unknown objectType and non-cube meshType TEXT and CURVE used to fall through to an EMPTY with an accepted receipt. Co-Authored-By: Claude Sonnet 5.5 --- .../blender/brokkr_bridge/blender_target.py | 12 +++++- surfaces/blender/docs/install.md | 4 ++ surfaces/blender/tests/test_scene_actions.py | 41 ++++++++++++++++++- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/surfaces/blender/brokkr_bridge/blender_target.py b/surfaces/blender/brokkr_bridge/blender_target.py index 628c199..dcae328 100644 --- a/surfaces/blender/brokkr_bridge/blender_target.py +++ b/surfaces/blender/brokkr_bridge/blender_target.py @@ -34,6 +34,8 @@ 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") + 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 @@ -518,6 +520,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)) @@ -527,7 +537,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 diff --git a/surfaces/blender/docs/install.md b/surfaces/blender/docs/install.md index 6548af4..04d5a1e 100644 --- a/surfaces/blender/docs/install.md +++ b/surfaces/blender/docs/install.md @@ -116,6 +116,10 @@ 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) is refused rather than ignored. - `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. diff --git a/surfaces/blender/tests/test_scene_actions.py b/surfaces/blender/tests/test_scene_actions.py index 6244188..aa4b498 100644 --- a/surfaces/blender/tests/test_scene_actions.py +++ b/surfaces/blender/tests/test_scene_actions.py @@ -86,7 +86,19 @@ class FakeBpy: worlds=NS(get=lambda name: None, new=self.new_world), ) self.path = NS(abspath=lambda p: p) - self.ops = NS(import_scene=NS(gltf=self.import_gltf), render=NS(render=self.render_still)) + self.created = [] + self.app = NS(background=False) + 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), + 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)}) @@ -117,7 +129,8 @@ 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) + self.context = NS(scene=self.bpy.scene, object=None) + self.bpy.on_add = lambda obj: setattr(self.context, "object", obj) def run_action(self, **command): command.setdefault("commandId", "c1") @@ -238,6 +251,30 @@ 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): + 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, []) + # 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", -- 2.47.3 From 6b7d6ae9e2db549313eab38e59c1990b323e2e6f Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 14:31:58 +0000 Subject: [PATCH 2/3] blender: renderStill refuses GL engines headless when libEGL is not loadable BLENDER_WORKBENCH and BLENDER_EEVEE aborted the whole Blender process inside bpy.ops.render.render with no receipt. The probe is cached, Linux only, and a module-level function so tests replace it. Co-Authored-By: Claude Sonnet 5.5 --- .../blender/brokkr_bridge/blender_target.py | 26 ++++++++++ surfaces/blender/docs/install.md | 7 ++- surfaces/blender/tests/test_scene_actions.py | 48 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/surfaces/blender/brokkr_bridge/blender_target.py b/surfaces/blender/brokkr_bridge/blender_target.py index dcae328..641928f 100644 --- a/surfaces/blender/brokkr_bridge/blender_target.py +++ b/surfaces/blender/brokkr_bridge/blender_target.py @@ -1,5 +1,7 @@ from __future__ import annotations +import ctypes +import functools import json import math import os @@ -36,6 +38,11 @@ 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 @@ -751,6 +758,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: @@ -1033,6 +1046,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() diff --git a/surfaces/blender/docs/install.md b/surfaces/blender/docs/install.md index 04d5a1e..6f3ad73 100644 --- a/surfaces/blender/docs/install.md +++ b/surfaces/blender/docs/install.md @@ -137,7 +137,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), 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 diff --git a/surfaces/blender/tests/test_scene_actions.py b/surfaces/blender/tests/test_scene_actions.py index aa4b498..88ff453 100644 --- a/surfaces/blender/tests/test_scene_actions.py +++ b/surfaces/blender/tests/test_scene_actions.py @@ -275,6 +275,54 @@ class ActionTests(unittest.TestCase): 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", -- 2.47.3 From 01d25c6bf98dc5683d64ea9beea50d31eee5762f Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 23:13:57 +0000 Subject: [PATCH 3/3] Blender createObject: pin whitespace meshType refusal, document meshType and null objectType Co-Authored-By: Claude Sonnet 5.5 --- surfaces/blender/docs/install.md | 5 +++-- surfaces/blender/tests/test_scene_actions.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/surfaces/blender/docs/install.md b/surfaces/blender/docs/install.md index 06ab1c9..f27093b 100644 --- a/surfaces/blender/docs/install.md +++ b/surfaces/blender/docs/install.md @@ -120,7 +120,8 @@ 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) is refused rather than ignored. + (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. @@ -159,7 +160,7 @@ gets a `failed` receipt whose message names the rule): 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 engines `BLENDER_WORKBENCH`, `BLENDER_EEVEE` and `BLENDER_EEVEE_NEXT` are refused - with a `failed` receipt when `libEGL.so.1` cannot be loaded (probed once), because + 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. diff --git a/surfaces/blender/tests/test_scene_actions.py b/surfaces/blender/tests/test_scene_actions.py index 1235776..bb3a292 100644 --- a/surfaces/blender/tests/test_scene_actions.py +++ b/surfaces/blender/tests/test_scene_actions.py @@ -397,7 +397,7 @@ class ActionTests(unittest.TestCase): self.assertEqual(self.bpy.created, []) def test_create_object_refuses_a_mesh_type_other_than_cube(self): - for mesh_type in ("CYLINDER", "", 1): + 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"]) -- 2.47.3