From b3afd70cf911b2314851d3778d3875ab1d272152 Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 14:31:39 +0000 Subject: [PATCH 1/2] Blender: add exportGltf action (extras, yUp, applyModifiers, selection) under the render output directory Mirrors renderStill path safety: no overwrite, no links, no escape. Advertises asset.export.gltf. Co-Authored-By: Claude Sonnet 5.5 --- brokkr-daemon/src/main.rs | 2 + .../blender/brokkr_bridge/blender_target.py | 53 +++++++++ .../blender/brokkr_bridge/scene_actions.py | 2 +- surfaces/blender/docs/install.md | 8 ++ surfaces/blender/tests/test_scene_actions.py | 104 +++++++++++++++++- 5 files changed, 167 insertions(+), 2 deletions(-) diff --git a/brokkr-daemon/src/main.rs b/brokkr-daemon/src/main.rs index 3b05166..48600c5 100644 --- a/brokkr-daemon/src/main.rs +++ b/brokkr-daemon/src/main.rs @@ -245,6 +245,7 @@ fn build_provider_advertisement() -> ProviderAdvertisement { "selection.read", "selection.write", "asset.import.gltf", + "asset.export.gltf", "camera.write", "light.write", "world.write", @@ -1994,6 +1995,7 @@ mod tests { assert!(blender.capabilities.contains(&"material.assign")); for scene_and_graph in [ "asset.import.gltf", + "asset.export.gltf", "camera.write", "light.write", "world.write", diff --git a/surfaces/blender/brokkr_bridge/blender_target.py b/surfaces/blender/brokkr_bridge/blender_target.py index 628c199..d5dacaf 100644 --- a/surfaces/blender/brokkr_bridge/blender_target.py +++ b/surfaces/blender/brokkr_bridge/blender_target.py @@ -52,6 +52,7 @@ CAPABILITIES = ( "selection.read", "selection.write", "asset.import.gltf", + "asset.export.gltf", "camera.write", "light.write", "world.write", @@ -499,6 +500,8 @@ class BrokkrBlenderTarget: return self._assign_material(context, command) if action == "importGltf": return self._import_gltf(context, command) + if action == "exportGltf": + return self._export_gltf(context, command) if action == "setCamera": return self._set_camera(context, command) if action == "setLight": @@ -636,6 +639,56 @@ class BrokkrBlenderTarget: 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 "") + 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)] + 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.") + objects = list(self.bpy.data.objects.values()) + saved = [(obj, obj.select_get()) for obj in objects] + try: + if chosen: + 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: obj = self._resolve_object(command) if obj is None: diff --git a/surfaces/blender/brokkr_bridge/scene_actions.py b/surfaces/blender/brokkr_bridge/scene_actions.py index ef0ada7..04c9566 100644 --- a/surfaces/blender/brokkr_bridge/scene_actions.py +++ b/surfaces/blender/brokkr_bridge/scene_actions.py @@ -98,7 +98,7 @@ def resolve_inside(root: str, candidate: str, label: str, *, new_file: bool = Fa except ValueError: raise ActionRefused(f"{label} path is outside the configured {label} directory.") from None if new_file and os.path.lexists(current): - raise ActionRefused(f"{label} path already exists; renderStill never overwrites or writes through an existing name.") + raise ActionRefused(f"{label} path already exists; output actions never overwrite or write through an existing name.") return current diff --git a/surfaces/blender/docs/install.md b/surfaces/blender/docs/install.md index 6548af4..0d68a0a 100644 --- a/surfaces/blender/docs/install.md +++ b/surfaces/blender/docs/install.md @@ -108,6 +108,7 @@ Admitted command actions: - `selectObject` - `assignMaterial` - `importGltf` +- `exportGltf` - `setCamera` - `setLight` - `setWorld` @@ -119,6 +120,13 @@ gets a `failed` receipt whose message names the rule): - `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. +- `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 refuses the export; the selection is restored afterwards). + The receipt message carries the written path and byte size. Needs Blender's glTF add-on. - `setCamera`: `targetObjectName` (a camera object); optional `lens` (mm), `cameraType` (`PERSP`, `ORTHO`, `PANO`), `panoramaType` (for example `EQUIRECTANGULAR`; read from the camera, or from `camera.cycles` before Blender 4.2), diff --git a/surfaces/blender/tests/test_scene_actions.py b/surfaces/blender/tests/test_scene_actions.py index 6244188..b9a7110 100644 --- a/surfaces/blender/tests/test_scene_actions.py +++ b/surfaces/blender/tests/test_scene_actions.py @@ -86,7 +86,15 @@ 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.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)) + self.ops = NS(import_scene=NS(gltf=self.import_gltf), render=NS(render=self.render_still), + export_scene=NS(gltf=self.export_gltf)) def new_world(self, name): background = NS(inputs={"Color": NS(default_value=None), "Strength": NS(default_value=None)}) @@ -99,6 +107,13 @@ class FakeBpy: self.imported.append(filepath) 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): r = self.scene.render self.rendered.append((r.engine, r.resolution_x, r.resolution_y, r.filepath, @@ -206,6 +221,93 @@ class ActionTests(unittest.TestCase): self.assertEqual(self.run_action(action="setWorld", color=[9, 0, 0])["status"], "failed") 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_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 def test_render_still_writes_inside_output_dir_and_restores_settings(self): receipt = self.run_action(action="renderStill", outputPath="shots/a.png", engine="cycles", -- 2.47.3 From 12f3612ca3af51944fc1663f1bdf70f6a8f53ae6 Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 18:01:24 +0000 Subject: [PATCH 2/2] exportGltf: touch the selection only when one is asked for, and only on view-layer objects select_set raises in Blender for an object with no base in the view layer, so a scene with off-layer objects could fail the export or mask its result. A selection naming an off-layer object is refused by name; no selection means the selection is never read or written. Docs state the unchecked texture sidecars and the TOCTOU residue. Co-Authored-By: Claude Sonnet 5.5 --- .../blender/brokkr_bridge/blender_target.py | 13 ++++++--- surfaces/blender/docs/install.md | 5 +++- surfaces/blender/tests/test_scene_actions.py | 27 ++++++++++++++++++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/surfaces/blender/brokkr_bridge/blender_target.py b/surfaces/blender/brokkr_bridge/blender_target.py index 6a1ab87..faaaf11 100644 --- a/surfaces/blender/brokkr_bridge/blender_target.py +++ b/surfaces/blender/brokkr_bridge/blender_target.py @@ -673,6 +673,11 @@ class BrokkrBlenderTarget: 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) @@ -680,12 +685,12 @@ class BrokkrBlenderTarget: # 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.") - objects = list(self.bpy.data.objects.values()) + # 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: - if chosen: - for obj in objects: - obj.select_set(obj in chosen) + 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) diff --git a/surfaces/blender/docs/install.md b/surfaces/blender/docs/install.md index b91c456..0de60b9 100644 --- a/surfaces/blender/docs/install.md +++ b/surfaces/blender/docs/install.md @@ -125,7 +125,10 @@ gets a `failed` receipt whose message names the rule): 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 refuses the export; the selection is restored afterwards). + 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), `cameraType` (`PERSP`, `ORTHO`, `PANO`), `panoramaType` (for example diff --git a/surfaces/blender/tests/test_scene_actions.py b/surfaces/blender/tests/test_scene_actions.py index b9a7110..43b4020 100644 --- a/surfaces/blender/tests/test_scene_actions.py +++ b/surfaces/blender/tests/test_scene_actions.py @@ -132,7 +132,7 @@ 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, view_layer=NS(objects=Objects(dict(self.bpy.data.objects.items())))) def run_action(self, **command): command.setdefault("commandId", "c1") @@ -249,6 +249,31 @@ class ActionTests(unittest.TestCase): 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") -- 2.47.3