Blender add-on: exportGltf action #7
5 changed files with 201 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue