Blender add-on: exportGltf action #7

Merged
libby merged 3 commits from libby/blender-export-gltf into main 2026-11-10 18:01:28 +00:00
5 changed files with 201 additions and 3 deletions

View file

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

View file

@ -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":
@ -646,6 +649,61 @@ 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)]
# 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)
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.")
# 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:
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:

View file

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

View file

@ -108,6 +108,7 @@ Admitted command actions:
- `selectObject`
- `assignMaterial`
- `importGltf`
- `exportGltf`
- `setCamera`
- `setLight`
- `setWorld`
@ -119,6 +120,16 @@ 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, 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
`EQUIRECTANGULAR`; read from the camera, or from `camera.cycles` before Blender 4.2),

View file

@ -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,
@ -117,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")
@ -206,6 +221,118 @@ 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_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")
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",