From 12f3612ca3af51944fc1663f1bdf70f6a8f53ae6 Mon Sep 17 00:00:00 2001 From: Libby Date: Tue, 10 Nov 2026 18:01:24 +0000 Subject: [PATCH] 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")