use anyhow::{Context, Result};
use oxihuman_core::parser::obj::parse_obj;
use oxihuman_core::parser::target::parse_target;
use oxihuman_core::policy::{Policy, PolicyProfile};
use oxihuman_mesh::mesh::MeshBuffers;
use oxihuman_mesh::normals::compute_normals;
use oxihuman_mesh::suit::apply_suit_flag;
use oxihuman_morph::engine::HumanEngine;
use oxihuman_morph::params::ParamState;
use crate::glb::export_glb;
pub struct PipelineConfig {
pub base_obj_path: std::path::PathBuf,
pub targets_dir: Option<std::path::PathBuf>,
pub max_targets: Option<usize>,
pub policy: Policy,
pub params: ParamState,
pub output_path: std::path::PathBuf,
}
impl PipelineConfig {
pub fn new(
base_obj_path: impl Into<std::path::PathBuf>,
output_path: impl Into<std::path::PathBuf>,
) -> Self {
PipelineConfig {
base_obj_path: base_obj_path.into(),
targets_dir: None,
max_targets: None,
policy: Policy::new(PolicyProfile::Standard),
params: ParamState::default(),
output_path: output_path.into(),
}
}
}
pub fn run_pipeline(config: PipelineConfig) -> Result<MeshBuffers> {
let obj_src = std::fs::read_to_string(&config.base_obj_path)
.with_context(|| format!("reading base OBJ: {}", config.base_obj_path.display()))?;
let base = parse_obj(&obj_src).with_context(|| "parsing base OBJ")?;
let mut engine = HumanEngine::new(base, config.policy);
if let Some(ref targets_dir) = config.targets_dir {
if targets_dir.exists() {
let max = config.max_targets.unwrap_or(usize::MAX);
let mut loaded = 0usize;
for entry in std::fs::read_dir(targets_dir)
.with_context(|| format!("reading targets dir: {}", targets_dir.display()))?
{
if loaded >= max {
break;
}
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "target").unwrap_or(false) {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
if let Ok(src) = std::fs::read_to_string(&path) {
if let Ok(target) = parse_target(&name, &src) {
engine.load_target(target, Box::new(|p: &ParamState| p.weight));
loaded += 1;
}
}
}
}
}
}
engine.set_params(config.params);
let morph_buffers = engine.build_mesh();
let mut mesh = MeshBuffers::from_morph(morph_buffers);
compute_normals(&mut mesh);
apply_suit_flag(&mut mesh);
export_glb(&mesh, &config.output_path)
.with_context(|| format!("exporting GLB to {}", config.output_path.display()))?;
Ok(mesh)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::glb::verify_glb_header;
use oxihuman_morph::params::ParamState;
use proptest::prelude::*;
fn makehuman_data_dir() -> std::path::PathBuf {
std::env::var("MAKEHUMAN_DATA_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("/tmp/oxihuman_nonexistent_data"))
}
fn base_obj() -> std::path::PathBuf {
makehuman_data_dir().join("3dobjs/base.obj")
}
fn targets_dir() -> std::path::PathBuf {
makehuman_data_dir().join("targets/bodyshapes")
}
#[test]
fn pipeline_produces_valid_glb() {
if !base_obj().exists() {
return; }
let out = std::path::PathBuf::from("/tmp/oxihuman_pipeline_test.glb");
let config = PipelineConfig {
base_obj_path: base_obj(),
targets_dir: Some(targets_dir()),
max_targets: Some(5),
policy: Policy::new(PolicyProfile::Standard),
params: ParamState::new(0.6, 0.4, 0.5, 0.3),
output_path: out.clone(),
};
let mesh = run_pipeline(config).expect("pipeline failed");
assert!(
mesh.positions.len() > 10_000,
"base mesh should have many vertices"
);
assert!(mesh.has_suit, "suit flag must be set");
verify_glb_header(&out).expect("GLB header invalid");
std::fs::remove_file(&out).ok();
}
#[test]
fn pipeline_no_targets_still_works() {
if !base_obj().exists() {
return;
}
let out = std::path::PathBuf::from("/tmp/oxihuman_notargets.glb");
let config = PipelineConfig::new(base_obj(), out.clone());
let mesh = run_pipeline(config).expect("pipeline (no targets) failed");
assert!(!mesh.positions.is_empty());
verify_glb_header(&out).expect("should succeed");
std::fs::remove_file(&out).ok();
}
#[test]
fn pipeline_vertex_positions_finite() {
if !base_obj().exists() {
return;
}
let out = std::path::PathBuf::from("/tmp/oxihuman_finite.glb");
let config = PipelineConfig {
base_obj_path: base_obj(),
targets_dir: Some(targets_dir()),
max_targets: Some(10),
policy: Policy::new(PolicyProfile::Standard),
params: ParamState::new(1.0, 1.0, 1.0, 1.0),
output_path: out.clone(),
};
let mesh = run_pipeline(config).expect("should succeed");
for pos in &mesh.positions {
assert!(pos[0].is_finite(), "non-finite x at {:?}", pos);
assert!(pos[1].is_finite(), "non-finite y at {:?}", pos);
assert!(pos[2].is_finite(), "non-finite z at {:?}", pos);
}
std::fs::remove_file(&out).ok();
}
proptest! {
#[test]
fn random_params_pipeline_finite_positions(
h in 0.0f32..=1.0f32,
w in 0.0f32..=1.0f32,
m in 0.0f32..=1.0f32,
a in 0.0f32..=1.0f32,
) {
let base_obj_path = base_obj();
if !base_obj_path.exists() { return Ok(()); }
use oxihuman_core::parser::obj::parse_obj;
let simple_obj = "v 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nvn 0 0 1\nf 1/1/1 2/2/1 3/3/1\n";
let base = parse_obj(simple_obj).expect("should succeed");
use oxihuman_morph::engine::HumanEngine;
use oxihuman_core::policy::{Policy, PolicyProfile};
let mut engine = HumanEngine::new(base, Policy::new(PolicyProfile::Standard));
engine.set_params(ParamState::new(h, w, m, a));
let morph_buf = engine.build_mesh();
use oxihuman_mesh::mesh::MeshBuffers;
use oxihuman_mesh::normals::compute_normals;
use oxihuman_mesh::integrity::check_positions_finite;
let mut mesh = MeshBuffers::from_morph(morph_buf);
compute_normals(&mut mesh);
prop_assert!(
check_positions_finite(&mesh),
"non-finite positions with h={} w={} m={} a={}",
h, w, m, a
);
}
}
}
use bytemuck::cast_slice;
use serde_json::json;
use std::io::Write;
use crate::blend_shapes::BlendShape;
use crate::material::PbrMaterial;
use crate::metadata::OxiHumanMeta;
use oxihuman_mesh::skeleton::Skeleton;
const EP_GLB_MAGIC: u32 = 0x46546C67; const EP_GLB_VER: u32 = 2;
const EP_CHUNK_JSON: u32 = 0x4E4F534A; const EP_CHUNK_BIN: u32 = 0x004E4942;
#[derive(Default)]
pub struct ExportPipeline {
material: Option<PbrMaterial>,
meta: Option<OxiHumanMeta>,
skeleton: Option<Skeleton>,
blend_shapes: Vec<BlendShape>,
include_tangents: bool,
include_colors: bool,
#[allow(dead_code)]
include_normals: bool,
}
impl ExportPipeline {
pub fn new() -> Self {
Self::default()
}
pub fn with_material(mut self, m: PbrMaterial) -> Self {
self.material = Some(m);
self
}
pub fn with_meta(mut self, m: OxiHumanMeta) -> Self {
self.meta = Some(m);
self
}
pub fn with_skeleton(mut self, s: Skeleton) -> Self {
self.skeleton = Some(s);
self
}
pub fn with_blend_shapes(mut self, shapes: Vec<BlendShape>) -> Self {
self.blend_shapes = shapes;
self
}
pub fn with_tangents(mut self) -> Self {
self.include_tangents = true;
self
}
pub fn with_colors(mut self) -> Self {
self.include_colors = true;
self
}
pub fn describe(&self) -> String {
let mut parts: Vec<&str> = Vec::new();
if self.material.is_some() {
parts.push("material");
}
if self.meta.is_some() {
parts.push("metadata");
}
if self.skeleton.is_some() {
parts.push("skeleton");
}
if !self.blend_shapes.is_empty() {
parts.push("blend_shapes");
}
if self.include_tangents {
parts.push("tangents");
}
if self.include_colors {
parts.push("colors");
}
if self.include_normals {
parts.push("normals");
}
if parts.is_empty() {
"ExportPipeline[minimal]".to_string()
} else {
format!("ExportPipeline[{}]", parts.join(", "))
}
}
pub fn export(self, mesh: &MeshBuffers, path: &std::path::Path) -> anyhow::Result<()> {
let n_verts = mesh.positions.len();
let n_idx = mesh.indices.len();
let pos_bytes: &[u8] = cast_slice(&mesh.positions);
let norm_bytes: &[u8] = cast_slice(&mesh.normals);
let uv_bytes: &[u8] = cast_slice(&mesh.uvs);
let idx_bytes: &[u8] = cast_slice(&mesh.indices);
let pos_offset = 0usize;
let norm_offset = pos_offset + pos_bytes.len();
let uv_offset = norm_offset + norm_bytes.len();
let idx_offset = uv_offset + uv_bytes.len();
let mut cursor = idx_offset + idx_bytes.len();
let has_tangents = self.include_tangents && !mesh.tangents.is_empty();
let tangent_offset = cursor;
if has_tangents {
let tb: &[u8] = cast_slice(&mesh.tangents);
cursor += tb.len();
}
let has_colors = self.include_colors && mesh.colors.is_some();
let color_offset = cursor;
if has_colors {
if let Some(cols) = mesh.colors.as_ref() {
let cb: &[u8] = cast_slice(cols.as_slice());
cursor += cb.len();
}
}
let mut morph_offsets: Vec<usize> = Vec::with_capacity(self.blend_shapes.len());
for shape in &self.blend_shapes {
morph_offsets.push(cursor);
cursor += shape.position_deltas.len() * std::mem::size_of::<[f32; 3]>();
}
let mut bin_data: Vec<u8> = Vec::with_capacity(cursor);
bin_data.extend_from_slice(pos_bytes);
bin_data.extend_from_slice(norm_bytes);
bin_data.extend_from_slice(uv_bytes);
bin_data.extend_from_slice(idx_bytes);
if has_tangents {
let tb: &[u8] = cast_slice(&mesh.tangents);
bin_data.extend_from_slice(tb);
}
if has_colors {
if let Some(cols) = mesh.colors.as_ref() {
let cb: &[u8] = cast_slice(cols.as_slice());
bin_data.extend_from_slice(cb);
}
}
for shape in &self.blend_shapes {
let db: &[u8] = cast_slice(&shape.position_deltas);
bin_data.extend_from_slice(db);
}
while !bin_data.len().is_multiple_of(4) {
bin_data.push(0x00);
}
let total_bin = bin_data.len() as u32;
let mut accessors: Vec<serde_json::Value> = Vec::new();
let mut buffer_views: Vec<serde_json::Value> = Vec::new();
buffer_views.push(json!({
"buffer": 0,
"byteOffset": pos_offset,
"byteLength": pos_bytes.len()
}));
let pos_acc = accessors.len();
accessors.push(json!({
"bufferView": buffer_views.len() - 1,
"componentType": 5126,
"count": n_verts,
"type": "VEC3"
}));
buffer_views.push(json!({
"buffer": 0,
"byteOffset": norm_offset,
"byteLength": norm_bytes.len()
}));
let norm_acc = accessors.len();
accessors.push(json!({
"bufferView": buffer_views.len() - 1,
"componentType": 5126,
"count": n_verts,
"type": "VEC3"
}));
buffer_views.push(json!({
"buffer": 0,
"byteOffset": uv_offset,
"byteLength": uv_bytes.len()
}));
let uv_acc = accessors.len();
accessors.push(json!({
"bufferView": buffer_views.len() - 1,
"componentType": 5126,
"count": n_verts,
"type": "VEC2"
}));
buffer_views.push(json!({
"buffer": 0,
"byteOffset": idx_offset,
"byteLength": idx_bytes.len()
}));
let idx_acc = accessors.len();
accessors.push(json!({
"bufferView": buffer_views.len() - 1,
"componentType": 5125,
"count": n_idx,
"type": "SCALAR"
}));
let tangent_acc: Option<usize> = if has_tangents {
let tb: &[u8] = cast_slice(&mesh.tangents);
buffer_views.push(json!({
"buffer": 0,
"byteOffset": tangent_offset,
"byteLength": tb.len()
}));
let acc = accessors.len();
accessors.push(json!({
"bufferView": buffer_views.len() - 1,
"componentType": 5126,
"count": n_verts,
"type": "VEC4"
}));
Some(acc)
} else {
None
};
let color_acc: Option<usize> = if let (true, Some(cols)) = (has_colors, mesh.colors.as_ref()) {
let cb: &[u8] = cast_slice(cols.as_slice());
buffer_views.push(json!({
"buffer": 0,
"byteOffset": color_offset,
"byteLength": cb.len()
}));
let acc = accessors.len();
accessors.push(json!({
"bufferView": buffer_views.len() - 1,
"componentType": 5126,
"count": n_verts,
"type": "VEC4"
}));
Some(acc)
} else {
None
};
let mut morph_targets: Vec<serde_json::Value> = Vec::new();
for (i, shape) in self.blend_shapes.iter().enumerate() {
let delta_len = shape.position_deltas.len() * std::mem::size_of::<[f32; 3]>();
buffer_views.push(json!({
"buffer": 0,
"byteOffset": morph_offsets[i],
"byteLength": delta_len
}));
let acc = accessors.len();
let mut acc_node = json!({
"bufferView": buffer_views.len() - 1,
"componentType": 5126,
"count": n_verts,
"type": "VEC3"
});
if !shape.position_deltas.is_empty() {
let mut mn = [f32::INFINITY; 3];
let mut mx = [f32::NEG_INFINITY; 3];
for d in &shape.position_deltas {
for k in 0..3 {
mn[k] = mn[k].min(d[k]);
mx[k] = mx[k].max(d[k]);
}
}
acc_node["min"] = json!([mn[0], mn[1], mn[2]]);
acc_node["max"] = json!([mx[0], mx[1], mx[2]]);
}
accessors.push(acc_node);
morph_targets.push(json!({ "POSITION": acc }));
}
let mut attributes = json!({
"POSITION": pos_acc,
"NORMAL": norm_acc,
"TEXCOORD_0": uv_acc
});
if let Some(t) = tangent_acc {
attributes["TANGENT"] = json!(t);
}
if let Some(c) = color_acc {
attributes["COLOR_0"] = json!(c);
}
let mut primitive = json!({
"attributes": attributes,
"indices": idx_acc
});
if !morph_targets.is_empty() {
primitive["targets"] = json!(morph_targets);
}
let morph_weights: Vec<f32> = vec![0.0; self.blend_shapes.len()];
let target_names: Vec<&str> = self.blend_shapes.iter().map(|s| s.name.as_str()).collect();
let mut mesh_node = json!({
"primitives": [primitive]
});
if !morph_weights.is_empty() {
mesh_node["weights"] = json!(morph_weights);
mesh_node["extras"] = json!({ "targetNames": target_names });
}
let mut materials_arr: Vec<serde_json::Value> = Vec::new();
if let Some(ref mat) = self.material {
materials_arr.push(mat.to_gltf_json());
mesh_node["primitives"][0]["material"] = json!(0);
}
let skins_val: Option<serde_json::Value>;
let mut nodes_arr: Vec<serde_json::Value> = Vec::new();
if let Some(ref skel) = self.skeleton {
let joint_node_start = 1usize; for joint in &skel.joints {
nodes_arr.push(json!({
"name": joint.name,
"translation": [
joint.translation[0],
joint.translation[1],
joint.translation[2]
]
}));
}
let joint_indices: Vec<usize> =
(joint_node_start..joint_node_start + skel.joints.len()).collect();
nodes_arr.insert(0, json!({ "mesh": 0, "skin": 0 }));
skins_val = Some(json!([{
"joints": joint_indices,
"name": "Armature"
}]));
} else {
nodes_arr.push(json!({ "mesh": 0 }));
skins_val = None;
}
let mut asset = json!({
"version": "2.0",
"generator": "oxihuman-export"
});
if let Some(ref m) = self.meta {
asset["extras"] = m.to_json();
}
let mut gltf = json!({
"asset": asset,
"scene": 0,
"scenes": [{ "nodes": [0] }],
"nodes": nodes_arr,
"meshes": [mesh_node],
"accessors": accessors,
"bufferViews": buffer_views,
"buffers": [{ "byteLength": total_bin }]
});
if !materials_arr.is_empty() {
gltf["materials"] = json!(materials_arr);
}
if let Some(s) = skins_val {
gltf["skins"] = s;
}
let mut json_bytes = serde_json::to_vec(&gltf)?;
while !json_bytes.len().is_multiple_of(4) {
json_bytes.push(b' ');
}
let json_chunk_len = json_bytes.len() as u32;
let bin_chunk_len = bin_data.len() as u32;
let total_len = 12 + 8 + json_chunk_len + 8 + bin_chunk_len;
let mut file = std::fs::File::create(path)?;
file.write_all(&EP_GLB_MAGIC.to_le_bytes())?;
file.write_all(&EP_GLB_VER.to_le_bytes())?;
file.write_all(&total_len.to_le_bytes())?;
file.write_all(&json_chunk_len.to_le_bytes())?;
file.write_all(&EP_CHUNK_JSON.to_le_bytes())?;
file.write_all(&json_bytes)?;
file.write_all(&bin_chunk_len.to_le_bytes())?;
file.write_all(&EP_CHUNK_BIN.to_le_bytes())?;
file.write_all(&bin_data)?;
Ok(())
}
}
#[cfg(test)]
mod export_pipeline_tests {
use super::*;
fn triangle() -> MeshBuffers {
MeshBuffers {
positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
normals: vec![[0.0, 0.0, 1.0]; 3],
uvs: vec![[0.0, 0.0]; 3],
tangents: vec![[1.0, 0.0, 0.0, 1.0]; 3],
colors: Some(vec![[1.0, 1.0, 1.0, 1.0]; 3]),
indices: vec![0, 1, 2],
has_suit: true,
}
}
#[test]
fn pipeline_minimal_export() {
let mesh = triangle();
let path = std::path::Path::new("/tmp/test_pipeline_minimal.glb");
ExportPipeline::new()
.export(&mesh, path)
.expect("minimal export failed");
assert!(path.exists(), "GLB file not created");
std::fs::remove_file(path).ok();
}
#[test]
fn pipeline_with_material() {
let mesh = triangle();
let path = std::path::Path::new("/tmp/test_pipeline_material.glb");
ExportPipeline::new()
.with_material(PbrMaterial::skin())
.export(&mesh, path)
.expect("material export failed");
assert!(path.exists(), "GLB file not created");
std::fs::remove_file(path).ok();
}
#[test]
fn pipeline_with_meta() {
let mesh = triangle();
let path = std::path::Path::new("/tmp/test_pipeline_meta.glb");
ExportPipeline::new()
.with_meta(OxiHumanMeta::minimal())
.export(&mesh, path)
.expect("meta export failed");
assert!(path.exists(), "GLB file not created");
let bytes = std::fs::read(path).expect("should succeed");
assert!(bytes.len() >= 4);
assert_eq!(&bytes[0..4], b"glTF", "invalid GLB magic");
std::fs::remove_file(path).ok();
}
#[test]
fn pipeline_describe_empty() {
let desc = ExportPipeline::new().describe();
assert!(
!desc.is_empty(),
"describe() should return non-empty string"
);
}
#[test]
fn pipeline_describe_with_features() {
let desc = ExportPipeline::new()
.with_material(PbrMaterial::skin())
.with_meta(OxiHumanMeta::minimal())
.describe();
assert!(
desc.contains("material"),
"describe() should mention 'material', got: {desc}"
);
}
#[test]
fn pipeline_with_blend_shapes() {
let mesh = triangle();
let path = std::path::Path::new("/tmp/test_pipeline_blend_shapes.glb");
let shapes = vec![BlendShape::zero("neutral", 3)];
ExportPipeline::new()
.with_blend_shapes(shapes)
.export(&mesh, path)
.expect("blend shapes export failed");
assert!(path.exists(), "GLB file not created");
std::fs::remove_file(path).ok();
}
#[test]
fn pipeline_with_tangents_and_colors() {
let mesh = triangle();
let path = std::path::Path::new("/tmp/test_pipeline_tangents_colors.glb");
ExportPipeline::new()
.with_tangents()
.with_colors()
.export(&mesh, path)
.expect("tangents+colors export failed");
assert!(path.exists(), "GLB file not created");
std::fs::remove_file(path).ok();
}
}