use std::io::Write;
use std::path::Path;
use oxihuman_mesh::MeshBuffers;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlyFormat {
Ascii,
BinaryLittleEndian,
}
fn write_ply_header(
writer: &mut impl Write,
format: PlyFormat,
vertex_count: usize,
face_count: usize,
has_normals: bool,
has_uvs: bool,
has_colors: bool,
) -> anyhow::Result<()> {
let format_str = match format {
PlyFormat::Ascii => "ascii 1.0",
PlyFormat::BinaryLittleEndian => "binary_little_endian 1.0",
};
writeln!(writer, "ply")?;
writeln!(writer, "format {}", format_str)?;
writeln!(writer, "comment Generated by OxiHuman")?;
writeln!(writer, "element vertex {}", vertex_count)?;
writeln!(writer, "property float x")?;
writeln!(writer, "property float y")?;
writeln!(writer, "property float z")?;
if has_normals {
writeln!(writer, "property float nx")?;
writeln!(writer, "property float ny")?;
writeln!(writer, "property float nz")?;
}
if has_uvs {
writeln!(writer, "property float s")?;
writeln!(writer, "property float t")?;
}
if has_colors {
writeln!(writer, "property uchar red")?;
writeln!(writer, "property uchar green")?;
writeln!(writer, "property uchar blue")?;
}
if face_count > 0 {
writeln!(writer, "element face {}", face_count)?;
writeln!(writer, "property list uchar int vertex_indices")?;
}
writeln!(writer, "end_header")?;
Ok(())
}
#[allow(dead_code)]
pub fn export_ply(mesh: &MeshBuffers, path: &Path, format: PlyFormat) -> anyhow::Result<()> {
let vertex_count = mesh.positions.len();
let face_count = mesh.indices.len() / 3;
let has_normals = !mesh.normals.is_empty();
let has_uvs = !mesh.uvs.is_empty();
let mut buf: Vec<u8> = Vec::new();
write_ply_header(
&mut buf,
format,
vertex_count,
face_count,
has_normals,
has_uvs,
false,
)?;
match format {
PlyFormat::Ascii => {
for i in 0..vertex_count {
let p = mesh.positions[i];
let mut line = format!("{} {} {}", p[0], p[1], p[2]);
if has_normals && i < mesh.normals.len() {
let n = mesh.normals[i];
line.push_str(&format!(" {} {} {}", n[0], n[1], n[2]));
}
if has_uvs && i < mesh.uvs.len() {
let uv = mesh.uvs[i];
line.push_str(&format!(" {} {}", uv[0], uv[1]));
}
writeln!(buf, "{}", line)?;
}
for tri in mesh.indices.chunks_exact(3) {
writeln!(buf, "3 {} {} {}", tri[0], tri[1], tri[2])?;
}
}
PlyFormat::BinaryLittleEndian => {
for i in 0..vertex_count {
let p = mesh.positions[i];
buf.write_all(&p[0].to_le_bytes())?;
buf.write_all(&p[1].to_le_bytes())?;
buf.write_all(&p[2].to_le_bytes())?;
if has_normals && i < mesh.normals.len() {
let n = mesh.normals[i];
buf.write_all(&n[0].to_le_bytes())?;
buf.write_all(&n[1].to_le_bytes())?;
buf.write_all(&n[2].to_le_bytes())?;
}
if has_uvs && i < mesh.uvs.len() {
let uv = mesh.uvs[i];
buf.write_all(&uv[0].to_le_bytes())?;
buf.write_all(&uv[1].to_le_bytes())?;
}
}
for tri in mesh.indices.chunks_exact(3) {
buf.write_all(&[3u8])?;
buf.write_all(&(tri[0] as i32).to_le_bytes())?;
buf.write_all(&(tri[1] as i32).to_le_bytes())?;
buf.write_all(&(tri[2] as i32).to_le_bytes())?;
}
}
}
std::fs::write(path, buf)?;
Ok(())
}
#[allow(dead_code)]
pub fn export_point_cloud_ply(
positions: &[[f32; 3]],
normals: Option<&[[f32; 3]]>,
colors: Option<&[[u8; 3]]>,
path: &Path,
format: PlyFormat,
) -> anyhow::Result<()> {
let vertex_count = positions.len();
let has_normals = normals.is_some();
let has_colors = colors.is_some();
let mut buf: Vec<u8> = Vec::new();
write_ply_header(
&mut buf,
format,
vertex_count,
0, has_normals,
false, has_colors,
)?;
match format {
PlyFormat::Ascii => {
for i in 0..vertex_count {
let p = positions[i];
let mut line = format!("{} {} {}", p[0], p[1], p[2]);
if let Some(nrm) = normals {
if i < nrm.len() {
let n = nrm[i];
line.push_str(&format!(" {} {} {}", n[0], n[1], n[2]));
}
}
if let Some(clr) = colors {
if i < clr.len() {
let c = clr[i];
line.push_str(&format!(" {} {} {}", c[0], c[1], c[2]));
}
}
writeln!(buf, "{}", line)?;
}
}
PlyFormat::BinaryLittleEndian => {
for i in 0..vertex_count {
let p = positions[i];
buf.write_all(&p[0].to_le_bytes())?;
buf.write_all(&p[1].to_le_bytes())?;
buf.write_all(&p[2].to_le_bytes())?;
if let Some(nrm) = normals {
if i < nrm.len() {
let n = nrm[i];
buf.write_all(&n[0].to_le_bytes())?;
buf.write_all(&n[1].to_le_bytes())?;
buf.write_all(&n[2].to_le_bytes())?;
}
}
if let Some(clr) = colors {
if i < clr.len() {
let c = clr[i];
buf.write_all(&[c[0], c[1], c[2]])?;
}
}
}
}
}
std::fs::write(path, buf)?;
Ok(())
}
#[allow(dead_code)]
pub fn export_mesh_as_point_cloud(
mesh: &MeshBuffers,
path: &Path,
format: PlyFormat,
) -> anyhow::Result<()> {
let normals = if mesh.normals.is_empty() {
None
} else {
Some(mesh.normals.as_slice())
};
export_point_cloud_ply(&mesh.positions, normals, None, path, format)
}
#[cfg(test)]
mod tests {
use super::*;
use oxihuman_mesh::MeshBuffers;
use oxihuman_morph::engine::MeshBuffers as MB;
fn triangle_mesh() -> MeshBuffers {
MeshBuffers::from_morph(MB {
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], [1.0, 0.0], [0.0, 1.0]],
indices: vec![0, 1, 2],
has_suit: false,
})
}
fn quad_mesh() -> MeshBuffers {
MeshBuffers::from_morph(MB {
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
],
normals: vec![[0.0, 0.0, 1.0]; 4],
uvs: vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
indices: vec![0, 1, 2, 0, 2, 3],
has_suit: false,
})
}
#[test]
fn export_ply_ascii_creates_file() {
let mesh = triangle_mesh();
let path = std::path::PathBuf::from("/tmp/test_ply_ascii_create.ply");
export_ply(&mesh, &path, PlyFormat::Ascii).expect("should succeed");
assert!(path.exists());
std::fs::remove_file(&path).ok();
}
#[test]
fn export_ply_ascii_header_starts_with_ply() {
let mesh = triangle_mesh();
let path = std::path::PathBuf::from("/tmp/test_ply_ascii_header.ply");
export_ply(&mesh, &path, PlyFormat::Ascii).expect("should succeed");
let content = std::fs::read_to_string(&path).expect("should succeed");
assert!(content.starts_with("ply\n"), "File must start with 'ply'");
assert!(content.contains("format ascii 1.0"));
assert!(content.contains("comment Generated by OxiHuman"));
std::fs::remove_file(&path).ok();
}
#[test]
fn export_ply_ascii_contains_vertex_count() {
let mesh = triangle_mesh();
let path = std::path::PathBuf::from("/tmp/test_ply_ascii_vcount.ply");
export_ply(&mesh, &path, PlyFormat::Ascii).expect("should succeed");
let content = std::fs::read_to_string(&path).expect("should succeed");
assert!(content.contains("element vertex 3"));
assert!(content.contains("element face 1"));
std::fs::remove_file(&path).ok();
}
#[test]
fn export_ply_binary_creates_file() {
let mesh = triangle_mesh();
let path = std::path::PathBuf::from("/tmp/test_ply_binary_create.ply");
export_ply(&mesh, &path, PlyFormat::BinaryLittleEndian).expect("should succeed");
assert!(path.exists());
std::fs::remove_file(&path).ok();
}
#[test]
fn export_ply_binary_header_in_file() {
let mesh = triangle_mesh();
let path = std::path::PathBuf::from("/tmp/test_ply_binary_header.ply");
export_ply(&mesh, &path, PlyFormat::BinaryLittleEndian).expect("should succeed");
let bytes = std::fs::read(&path).expect("should succeed");
let header_end = b"end_header\n";
let found = bytes.windows(header_end.len()).any(|w| w == header_end);
assert!(found, "Binary PLY must contain 'end_header'");
assert!(bytes.starts_with(b"ply\n"));
assert!(bytes
.windows(b"binary_little_endian 1.0".len())
.any(|w| w == b"binary_little_endian 1.0"));
std::fs::remove_file(&path).ok();
}
#[test]
fn export_point_cloud_no_normals_no_colors() {
let positions: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
let path = std::path::PathBuf::from("/tmp/test_ply_pc_bare.ply");
export_point_cloud_ply(&positions, None, None, &path, PlyFormat::Ascii).expect("should succeed");
assert!(path.exists());
let content = std::fs::read_to_string(&path).expect("should succeed");
assert!(content.contains("element vertex 3"));
assert!(!content.contains("element face"));
assert!(!content.contains("property float nx"));
assert!(!content.contains("property uchar red"));
std::fs::remove_file(&path).ok();
}
#[test]
fn export_point_cloud_with_normals() {
let positions: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
let normals: Vec<[f32; 3]> = vec![[0.0, 0.0, 1.0], [0.0, 0.0, 1.0]];
let path = std::path::PathBuf::from("/tmp/test_ply_pc_normals.ply");
export_point_cloud_ply(&positions, Some(&normals), None, &path, PlyFormat::Ascii).expect("should succeed");
let content = std::fs::read_to_string(&path).expect("should succeed");
assert!(content.contains("property float nx"));
assert!(content.contains("property float ny"));
assert!(content.contains("property float nz"));
std::fs::remove_file(&path).ok();
}
#[test]
fn export_point_cloud_with_colors() {
let positions: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
let colors: Vec<[u8; 3]> = vec![[255, 0, 0], [0, 255, 0]];
let path = std::path::PathBuf::from("/tmp/test_ply_pc_colors.ply");
export_point_cloud_ply(&positions, None, Some(&colors), &path, PlyFormat::Ascii).expect("should succeed");
let content = std::fs::read_to_string(&path).expect("should succeed");
assert!(content.contains("property uchar red"));
assert!(content.contains("property uchar green"));
assert!(content.contains("property uchar blue"));
assert!(content.contains("255 0 0") || content.contains("255"));
std::fs::remove_file(&path).ok();
}
#[test]
fn export_mesh_as_point_cloud_creates_file() {
let mesh = triangle_mesh();
let path = std::path::PathBuf::from("/tmp/test_ply_mesh_pc.ply");
export_mesh_as_point_cloud(&mesh, &path, PlyFormat::Ascii).expect("should succeed");
assert!(path.exists());
let content = std::fs::read_to_string(&path).expect("should succeed");
assert!(!content.contains("element face"));
assert!(content.contains("element vertex 3"));
std::fs::remove_file(&path).ok();
}
#[test]
fn ply_ascii_file_has_correct_line_count() {
let mesh = triangle_mesh();
let path = std::path::PathBuf::from("/tmp/test_ply_linecount.ply");
export_ply(&mesh, &path, PlyFormat::Ascii).expect("should succeed");
let content = std::fs::read_to_string(&path).expect("should succeed");
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 19, "Expected 19 lines, got {}", lines.len());
std::fs::remove_file(&path).ok();
}
#[test]
fn ply_ascii_vertex_data_is_numeric() {
let mesh = triangle_mesh();
let path = std::path::PathBuf::from("/tmp/test_ply_numeric.ply");
export_ply(&mesh, &path, PlyFormat::Ascii).expect("should succeed");
let content = std::fs::read_to_string(&path).expect("should succeed");
let lines: Vec<&str> = content.lines().collect();
let header_end = lines
.iter()
.position(|l| *l == "end_header")
.expect("end_header not found");
for line in &lines[header_end + 1..header_end + 1 + 3] {
for token in line.split_whitespace() {
token
.parse::<f32>()
.unwrap_or_else(|_| panic!("Token '{}' in vertex line is not numeric", token));
}
}
std::fs::remove_file(&path).ok();
}
#[test]
fn ply_format_ascii_vs_binary_different_size() {
let mesh = quad_mesh();
let ascii_path = std::path::PathBuf::from("/tmp/test_ply_size_ascii.ply");
let binary_path = std::path::PathBuf::from("/tmp/test_ply_size_binary.ply");
export_ply(&mesh, &ascii_path, PlyFormat::Ascii).expect("should succeed");
export_ply(&mesh, &binary_path, PlyFormat::BinaryLittleEndian).expect("should succeed");
let ascii_size = std::fs::metadata(&ascii_path).expect("should succeed").len();
let binary_size = std::fs::metadata(&binary_path).expect("should succeed").len();
assert_ne!(
ascii_size, binary_size,
"ASCII and binary sizes should differ"
);
std::fs::remove_file(&ascii_path).ok();
std::fs::remove_file(&binary_path).ok();
}
}