Blender add-on: createObject and renderStill refuse what they cannot do #8
3 changed files with 136 additions and 4 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue