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}