blend_converter/lib.rs
1#![warn(clippy::unwrap_used, missing_docs)]
2
3//! Blend Converter provides a convenient way to automatically convert blender files (.blend) to
4//! other 3D file formats that are easier to work with. Currently the only formats specified are
5//! gltf based (see [`OutputFormat`]).
6//!
7//! # Blender Executable
8//! To convert blends we need a blender executable. By default we check the path for `blender` and
9//! flatpak but if you need to specify a path use [`ConversionOptions::blender_path`]. For more
10//! details about the search strategy see [`BlenderExecutable`].
11//!
12//! # Example
13//!
14//! ```
15//! use std::path::Path;
16//! use blend_converter::ConversionOptions;
17//!
18//! let input_dir = Path::new("blends");
19//! let output_dir = Path::new("gltfs");
20//! ConversionOptions::new().convert_dir(input_dir, output_dir).unwrap();
21//! ```
22//!
23//! # Build Script
24//! You can use this in your build.rs to automatically convert blender files when they change. To
25//! do so your build.rs should look something like:
26//!
27//! ```no_run
28//! use std::path::Path;
29//!
30//! let input_dir = Path::new("blends");
31//! blend_converter::ConversionOptions::default()
32//! .convert_dir_build_script(input_dir)
33//! .expect("failed to convert blends");
34//! println!("cargo:rerun-if-changed={}", input_dir.display());
35//! println!("cargo:rerun-if-changed=build.rs");
36//! ```
37//! Then assuming you have `blends/test.blend`, in your code you can open the converted files using something like:
38//!
39//! ```ignore
40//! use std::path::Path;
41//!
42//! let path = Path::new(env!("OUT_DIR")).join("blends").join("test.glb");
43//! let f = std::fs::File::open(path);
44//! ```
45//!
46
47use std::env;
48use std::ffi::OsStr;
49use std::path::{Path, PathBuf};
50use std::process::{Command, ExitStatus};
51use walkdir::WalkDir;
52
53/// ConversionOptions describe how blender files should be converted
54#[derive(Debug)]
55pub struct ConversionOptions {
56 /// Ouput format is the desired file format to convert to
57 pub output_format: OutputFormat,
58 /// Blender path is an optional override path for where to search for blender. If it is None
59 /// [`BlenderExecutable::find`] will be used. Read the documentation there for the search
60 /// strategy.
61 pub blender_path: Option<PathBuf>,
62 /// Checks if the export path file already exists and does not overwrite it.
63 pub check_existing: bool,
64 /// Apply modifiers exclusing Armatures to mesh objects. Prevents exporting shape keys. Defaults to false.
65 pub apply_modifiers: bool,
66 /// Export custom properties as glTF extras. Defaults to false.
67 pub extras: bool,
68 /// Export using +Y as up instead of blender's +Z. Defaults to true.
69 pub yup: bool,
70}
71
72impl Default for ConversionOptions {
73 fn default() -> Self {
74 Self {
75 output_format: OutputFormat::default(),
76 check_existing: false,
77 blender_path: None,
78 apply_modifiers: false,
79 extras: false,
80 yup: true,
81 }
82 }
83}
84
85/// The output file format to export to
86///
87/// The default format is [`OutputFormat::Glb`]
88#[derive(Debug, Default)]
89pub enum OutputFormat {
90 /// glTF Binary (.glb) Exports a single file, with all data packed in binary form
91 #[default]
92 Glb,
93 /// glTF Embedded (.gltf) Exports a single file, with all data packed in JSON
94 GltfEmbedded,
95 /// glTF Separate (.gltf + .bin + textures) Exports multiple files, with separate JSON, binary
96 /// and texture data
97 GltfSeparate,
98}
99
100impl ConversionOptions {
101 fn export_script(&self, file_path: &Path) -> String {
102 let format = match &self.output_format {
103 OutputFormat::Glb => "GLB",
104 OutputFormat::GltfEmbedded => "GLTF_EMBEDDED",
105 OutputFormat::GltfSeparate => "GLTF_SEPARATE",
106 };
107 let check_existing = format_py_bool(self.check_existing);
108 let apply_modifiers = format_py_bool(self.apply_modifiers);
109 let extras = format_py_bool(self.extras);
110 let yup = format_py_bool(self.yup);
111 format!(
112 "import bpy; bpy.ops.export_scene.gltf(
113 filepath={file_path:?},
114 export_format={format:?},
115 check_existing={check_existing},
116 export_apply={apply_modifiers},
117 export_extras={extras},
118 export_yup={yup},
119 )"
120 )
121 }
122}
123
124fn format_py_bool(val: bool) -> &'static str {
125 if val {
126 "True"
127 } else {
128 "False"
129 }
130}
131
132impl ConversionOptions {
133 /// Create a new ConversionOptions with default output format of [`OutputFormat::Glb`].
134 ///
135 /// This is equivalent to [`ConversionOptions::default`]
136 pub fn new() -> Self {
137 Self::default()
138 }
139
140 /// Walks a directory and converts all the blend files while preserving the directory
141 /// structure.
142 pub fn convert_dir(&self, input_dir: &Path, output_dir: &Path) -> Result<(), Error> {
143 let blender_exe = BlenderExecutable::find_using_options(self)?;
144 for entry in WalkDir::new(input_dir)
145 .into_iter()
146 .filter_map(|entry| entry.ok())
147 {
148 if let Ok(m) = entry.metadata() {
149 // Ignore directories
150 if !m.is_file() {
151 continue;
152 }
153
154 let input_path = entry.path();
155
156 // Ignore everything except blend files
157 if input_path.extension() != Some(OsStr::new("blend")) {
158 continue;
159 }
160
161 let base;
162 if let Some(entry_parent) = input_path.parent() {
163 base = entry_parent;
164 } else {
165 base = Path::new(".");
166 }
167 let stem = input_path
168 .file_stem()
169 .ok_or(Error::InvalidInputFile(input_path.to_path_buf()))?;
170 let output_path = Path::new(&output_dir).join(base).join(stem);
171 std::fs::create_dir_all(output_path.parent().expect("walkdir must have parent"))?;
172
173 self.convert_internal(input_path, &output_path, &blender_exe)?;
174 }
175 }
176 Ok(())
177 }
178
179 /// Walks a directory converts all the blend files while preserving the directory structure but
180 /// outputs them to OUT_DIR.
181 ///
182 /// For use in build scripts only.
183 ///
184 /// # Example
185 ///
186 /// ```no_run
187 /// use std::path::Path;
188 ///
189 /// let input_dir = Path::new("blends");
190 /// blend_converter::ConversionOptions::default()
191 /// .convert_dir_build_script(input_dir)
192 /// .expect("failed to convert blends");
193 /// println!("cargo:rerun-if-changed={}", input_dir.display());
194 /// println!("cargo:rerun-if-changed=build.rs");
195 /// ```
196 pub fn convert_dir_build_script(&self, input_dir: &Path) -> Result<(), Error> {
197 let output_dir_env =
198 env::var("OUT_DIR").expect("OUT_DIR is not set, this must be called from build.rs");
199 let output_dir = Path::new(&output_dir_env);
200 self.convert_dir(input_dir, output_dir)
201 }
202
203 /// Convert an individual blend file
204 pub fn convert(&self, input: &Path, output: &Path) -> Result<(), Error> {
205 let blender_exe = BlenderExecutable::find_using_options(self)?;
206 self.convert_internal(input, output, &blender_exe)
207 }
208
209 fn convert_internal(
210 &self,
211 input: &Path,
212 output: &Path,
213 blender_exe: &BlenderExecutable,
214 ) -> Result<(), Error> {
215 let input_file_path = input.canonicalize()?;
216 if input_file_path
217 .extension()
218 .ok_or(Error::InvalidInputFile(input_file_path.clone()))?
219 != "blend"
220 {
221 return Err(Error::InvalidInputFile(input_file_path));
222 }
223
224 let status = blender_exe
225 .cmd()
226 .arg("-b")
227 .arg(input_file_path)
228 .arg("--python-exit-code")
229 .arg("10")
230 .arg("--python-expr")
231 .arg(self.export_script(output))
232 .status()?;
233
234 dbg!(status);
235 if status.success() {
236 Ok(())
237 } else {
238 Err(Error::Export(status))
239 }
240 }
241}
242
243/// The blender executable search strategy
244#[derive(Debug, Default)]
245pub enum BlenderExecutable {
246 /// Invokes blender using `blender` because blender is in the path environment variable
247 #[default]
248 Normal,
249 /// Invokes blender using `flatpak run org.blender.Blender`
250 Flatpak,
251 /// Invokes blender using path provided by [`ConversionOptions::blender_path`]
252 Path(PathBuf),
253}
254
255impl BlenderExecutable {
256 fn find_using_options(options: &ConversionOptions) -> Result<Self, Error> {
257 if let Some(path) = &options.blender_path {
258 BlenderExecutable::find_using_path(path)
259 } else {
260 BlenderExecutable::find()
261 }
262 }
263
264 /// Find tries [`BlenderExecutable::Normal`] then [`BlenderExecutable::Flatpak`] and returns
265 /// the first one that succeeds otherwise returns [`Error::MissingBlenderExecutable`]
266 pub fn find() -> Result<Self, Error> {
267 vec![Self::Normal, Self::Flatpak]
268 .into_iter()
269 .find(|x| matches!(x.test(), Ok(true)))
270 .ok_or(Error::MissingBlenderExecutable)
271 }
272
273 /// Only tries `path` as the blender executable and if it succeeds returns
274 /// [`BlenderExecutable::Path`] with the `path` otherwise returns
275 /// [`Error::MissingBlenderExecutable`]
276 pub fn find_using_path(path: &Path) -> Result<Self, Error> {
277 let s = Self::Path(path.to_path_buf());
278 if matches!(s.test(), Ok(true)) {
279 Ok(s)
280 } else {
281 Err(Error::MissingBlenderExecutable)
282 }
283 }
284
285 fn cmd(&self) -> Command {
286 match self {
287 Self::Normal => Command::new("blender"),
288 Self::Flatpak => {
289 let mut command = Command::new("flatpak");
290 command.arg("run").arg("org.blender.Blender");
291 command
292 }
293 Self::Path(path) => Command::new(path),
294 }
295 }
296
297 fn test(&self) -> std::io::Result<bool> {
298 Ok(self.cmd().arg("-b").arg("-v").status()?.success())
299 }
300}
301
302/// Errors for converting blends
303#[derive(Debug, thiserror::Error)]
304pub enum Error {
305 /// Could not locate blender executable, see [`BlenderExecutable`] for the search strategy
306 #[error("could not locate blender executable, is blender in your path?")]
307 MissingBlenderExecutable,
308 /// Invalid input file blend. This error occurs when the file extension is not .blend
309 #[error("invalid input path {0:?}, path must have .blend file extension")]
310 InvalidInputFile(PathBuf),
311 /// Export failed with exit code
312 #[error("export failed with exit code {0}")]
313 Export(ExitStatus),
314 /// IOError when exporting
315 #[error("io error occurred: {0}")]
316 IOError(#[from] std::io::Error),
317}
318
319#[cfg(test)]
320mod tests {
321 use std::path::Path;
322
323 #[test]
324 fn export_test_blend() {
325 let options = crate::ConversionOptions::default();
326 let export_path = Path::new(".").canonicalize().expect("abs path").join("test.glb");
327 options.convert(Path::new("./test.blend"), &export_path).expect("convert blend");
328 assert!(matches!(export_path.try_exists(), Ok(true)));
329 }
330}