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 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-10 18:01:24 +00:00
commit 12f3612ca3
3 changed files with 39 additions and 6 deletions

View file

@ -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)

View file

@ -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

View file

@ -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")