#![warn(clippy::unwrap_used, missing_docs)]
use std::env;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use walkdir::WalkDir;
#[derive(Debug)]
pub struct ConversionOptions {
pub output_format: OutputFormat,
pub blender_path: Option<PathBuf>,
pub check_existing: bool,
pub apply_modifiers: bool,
pub extras: bool,
pub yup: bool,
}
impl Default for ConversionOptions {
fn default() -> Self {
Self {
output_format: OutputFormat::default(),
check_existing: false,
blender_path: None,
apply_modifiers: false,
extras: false,
yup: true,
}
}
}
#[derive(Debug, Default)]
pub enum OutputFormat {
#[default]
Glb,
GltfEmbedded,
GltfSeparate,
}
impl ConversionOptions {
fn export_script(&self, file_path: &Path) -> String {
let format = match &self.output_format {
OutputFormat::Glb => "GLB",
OutputFormat::GltfEmbedded => "GLTF_EMBEDDED",
OutputFormat::GltfSeparate => "GLTF_SEPARATE",
};
let check_existing = format_py_bool(self.check_existing);
let apply_modifiers = format_py_bool(self.apply_modifiers);
let extras = format_py_bool(self.extras);
let yup = format_py_bool(self.yup);
format!(
"import bpy; bpy.ops.export_scene.gltf(
filepath={file_path:?},
export_format={format:?},
check_existing={check_existing},
export_apply={apply_modifiers},
export_extras={extras},
export_yup={yup},
)"
)
}
}
fn format_py_bool(val: bool) -> &'static str {
if val {
"True"
} else {
"False"
}
}
impl ConversionOptions {
pub fn new() -> Self {
Self::default()
}
pub fn convert_dir(&self, input_dir: &Path, output_dir: &Path) -> Result<(), Error> {
let blender_exe = BlenderExecutable::find_using_options(self)?;
for entry in WalkDir::new(input_dir)
.into_iter()
.filter_map(|entry| entry.ok())
{
if let Ok(m) = entry.metadata() {
if !m.is_file() {
continue;
}
let input_path = entry.path();
if input_path.extension() != Some(OsStr::new("blend")) {
continue;
}
let base;
if let Some(entry_parent) = input_path.parent() {
base = entry_parent;
} else {
base = Path::new(".");
}
let stem = input_path
.file_stem()
.ok_or(Error::InvalidInputFile(input_path.to_path_buf()))?;
let output_path = Path::new(&output_dir).join(base).join(stem);
std::fs::create_dir_all(output_path.parent().expect("walkdir must have parent"))?;
self.convert_internal(input_path, &output_path, &blender_exe)?;
}
}
Ok(())
}
pub fn convert_dir_build_script(&self, input_dir: &Path) -> Result<(), Error> {
let output_dir_env =
env::var("OUT_DIR").expect("OUT_DIR is not set, this must be called from build.rs");
let output_dir = Path::new(&output_dir_env);
self.convert_dir(input_dir, output_dir)
}
pub fn convert(&self, input: &Path, output: &Path) -> Result<(), Error> {
let blender_exe = BlenderExecutable::find_using_options(self)?;
self.convert_internal(input, output, &blender_exe)
}
fn convert_internal(
&self,
input: &Path,
output: &Path,
blender_exe: &BlenderExecutable,
) -> Result<(), Error> {
let input_file_path = input.canonicalize()?;
if input_file_path
.extension()
.ok_or(Error::InvalidInputFile(input_file_path.clone()))?
!= "blend"
{
return Err(Error::InvalidInputFile(input_file_path));
}
let status = blender_exe
.cmd()
.arg("-b")
.arg(input_file_path)
.arg("--python-exit-code")
.arg("10")
.arg("--python-expr")
.arg(self.export_script(output))
.status()?;
dbg!(status);
if status.success() {
Ok(())
} else {
Err(Error::Export(status))
}
}
}
#[derive(Debug, Default)]
pub enum BlenderExecutable {
#[default]
Normal,
Flatpak,
Path(PathBuf),
}
impl BlenderExecutable {
fn find_using_options(options: &ConversionOptions) -> Result<Self, Error> {
if let Some(path) = &options.blender_path {
BlenderExecutable::find_using_path(path)
} else {
BlenderExecutable::find()
}
}
pub fn find() -> Result<Self, Error> {
vec![Self::Normal, Self::Flatpak]
.into_iter()
.find(|x| matches!(x.test(), Ok(true)))
.ok_or(Error::MissingBlenderExecutable)
}
pub fn find_using_path(path: &Path) -> Result<Self, Error> {
let s = Self::Path(path.to_path_buf());
if matches!(s.test(), Ok(true)) {
Ok(s)
} else {
Err(Error::MissingBlenderExecutable)
}
}
fn cmd(&self) -> Command {
match self {
Self::Normal => Command::new("blender"),
Self::Flatpak => {
let mut command = Command::new("flatpak");
command.arg("run").arg("org.blender.Blender");
command
}
Self::Path(path) => Command::new(path),
}
}
fn test(&self) -> std::io::Result<bool> {
Ok(self.cmd().arg("-b").arg("-v").status()?.success())
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("could not locate blender executable, is blender in your path?")]
MissingBlenderExecutable,
#[error("invalid input path {0:?}, path must have .blend file extension")]
InvalidInputFile(PathBuf),
#[error("export failed with exit code {0}")]
Export(ExitStatus),
#[error("io error occurred: {0}")]
IOError(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use std::path::Path;
#[test]
fn export_test_blend() {
let options = crate::ConversionOptions::default();
let export_path = Path::new(".").canonicalize().expect("abs path").join("test.glb");
options.convert(Path::new("./test.blend"), &export_path).expect("convert blend");
assert!(matches!(export_path.try_exists(), Ok(true)));
}
}