From ae30496bfcb082bd62b5e6e0b1eea6e51f0c2cb6 Mon Sep 17 00:00:00 2001 From: nash1111 Date: Sat, 7 Feb 2026 23:25:41 +0900 Subject: [PATCH] test: expand export format tests with programmatic mesh generation Add comprehensive tests for all export formats using meshes generated by marching cubes and voxel algorithms: - STL: sphere mesh export, normal directions, face structure validation, coordinate preservation - OBJ: sphere mesh export, index validation, surface extraction, vertex precision - VTK: voxel mesh export, cell index range validation, shared vertices - glTF/GLB: sphere mesh export, chunk structure parsing, accessor counts, min/max bounds, two-chunk validation - Quantized GLB: sphere mesh export, node matrix presence, i16 min/max, binary buffer size, side-by-side comparison with regular GLB Test count: 80 -> 104 (+24 new tests) Closes #45 Co-Authored-By: Claude Opus 4.6 --- src/export/gltf.rs | 185 +++++++++++++++++++++++++++++++++++ src/export/gltf_quantized.rs | 141 ++++++++++++++++++++++++++ src/export/obj.rs | 138 ++++++++++++++++++++++++++ src/export/stl.rs | 115 ++++++++++++++++++++++ src/export/vtk.rs | 113 +++++++++++++++++++++ 5 files changed, 692 insertions(+) diff --git a/src/export/gltf.rs b/src/export/gltf.rs index 00e7709..bf36fe9 100644 --- a/src/export/gltf.rs +++ b/src/export/gltf.rs @@ -398,4 +398,189 @@ mod tests { assert_eq!(&glb[0..4], b"glTF"); assert_eq!(glb.len() % 4, 0); } + + #[test] + fn test_gltf_from_marching_cubes_sphere() { + use crate::marching_cubes::marching_cubes; + let min = Point3D { + index: 0, + x: -2.0, + y: -2.0, + z: -2.0, + }; + let max = Point3D { + index: 0, + x: 2.0, + y: 2.0, + z: 2.0, + }; + let faces = marching_cubes( + 6, + 6, + 6, + min, + max, + &|x, y, z| x * x + y * y + z * z - 1.0, + 0.0, + ); + assert!(!faces.is_empty()); + + let json = faces_to_gltf(&faces); + assert!(json.contains("\"version\":\"2.0\"")); + assert!(json.contains("\"generator\":\"meshing\"")); + assert!(json.contains("data:application/octet-stream;base64,")); + // Vertex count should match unique vertices + assert!(json.contains("\"componentType\":5126")); // FLOAT + assert!(json.contains("\"componentType\":5125")); // UNSIGNED_INT + } + + #[test] + fn test_glb_from_marching_cubes_sphere() { + use crate::marching_cubes::marching_cubes; + let min = Point3D { + index: 0, + x: -2.0, + y: -2.0, + z: -2.0, + }; + let max = Point3D { + index: 0, + x: 2.0, + y: 2.0, + z: 2.0, + }; + let faces = marching_cubes( + 6, + 6, + 6, + min, + max, + &|x, y, z| x * x + y * y + z * z - 1.0, + 0.0, + ); + assert!(!faces.is_empty()); + + let glb = faces_to_glb(&faces); + + // Header validation + assert_eq!(&glb[0..4], b"glTF"); + let version = u32::from_le_bytes([glb[4], glb[5], glb[6], glb[7]]); + assert_eq!(version, 2); + let total = u32::from_le_bytes([glb[8], glb[9], glb[10], glb[11]]); + assert_eq!(total as usize, glb.len()); + assert_eq!(glb.len() % 4, 0); + + // JSON chunk + let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize; + let json_type = u32::from_le_bytes([glb[16], glb[17], glb[18], glb[19]]); + assert_eq!(json_type, 0x4E4F534A); // "JSON" + assert_eq!(json_len % 4, 0); // padded + + let json = std::str::from_utf8(&glb[20..20 + json_len]).unwrap().trim(); + assert!(json.contains("\"POSITION\":0")); + assert!(json.contains("\"indices\":1")); + + // BIN chunk + let bin_offset = 20 + json_len; + let bin_len = u32::from_le_bytes([ + glb[bin_offset], + glb[bin_offset + 1], + glb[bin_offset + 2], + glb[bin_offset + 3], + ]) as usize; + let bin_type = u32::from_le_bytes([ + glb[bin_offset + 4], + glb[bin_offset + 5], + glb[bin_offset + 6], + glb[bin_offset + 7], + ]); + assert_eq!(bin_type, 0x004E4942); // "BIN\0" + assert!(bin_len > 0); + } + + #[test] + fn test_glb_accessor_counts_match_mesh() { + let faces = vec![ + Face { + a: Point3D { + index: 0, + x: 0.0, + y: 0.0, + z: 0.0, + }, + b: Point3D { + index: 1, + x: 1.0, + y: 0.0, + z: 0.0, + }, + c: Point3D { + index: 2, + x: 0.0, + y: 1.0, + z: 0.0, + }, + }, + Face { + a: Point3D { + index: 0, + x: 0.0, + y: 0.0, + z: 0.0, + }, + b: Point3D { + index: 2, + x: 0.0, + y: 1.0, + z: 0.0, + }, + c: Point3D { + index: 3, + x: 0.0, + y: 0.0, + z: 1.0, + }, + }, + ]; + let json = faces_to_gltf(&faces); + // 4 unique vertices, 6 indices + assert!(json.contains("\"count\":4")); + assert!(json.contains("\"count\":6")); + } + + #[test] + fn test_glb_min_max_bounds() { + let face = Face { + a: Point3D { + index: 0, + x: -1.0, + y: -2.0, + z: -3.0, + }, + b: Point3D { + index: 1, + x: 4.0, + y: 5.0, + z: 6.0, + }, + c: Point3D { + index: 2, + x: 0.0, + y: 0.0, + z: 0.0, + }, + }; + let json = faces_to_gltf(&[face]); + assert!(json.contains("\"min\":[-1,-2,-3]")); + assert!(json.contains("\"max\":[4,5,6]")); + } + + #[test] + fn test_glb_two_chunks_present() { + let glb = faces_to_glb(&[test_face()]); + let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize; + // After JSON chunk (8 header + json_len), there should be a BIN chunk + let bin_offset = 20 + json_len; + assert!(glb.len() > bin_offset + 8); // BIN chunk header exists + } } diff --git a/src/export/gltf_quantized.rs b/src/export/gltf_quantized.rs index 1c7d05e..458374a 100644 --- a/src/export/gltf_quantized.rs +++ b/src/export/gltf_quantized.rs @@ -416,4 +416,145 @@ mod tests { let glb = tetrahedra_to_glb_quantized(&[tet]); assert_eq!(&glb[0..4], b"glTF"); } + + #[test] + fn test_quantized_from_marching_cubes() { + use crate::marching_cubes::marching_cubes; + let min = Point3D { + index: 0, + x: -2.0, + y: -2.0, + z: -2.0, + }; + let max = Point3D { + index: 0, + x: 2.0, + y: 2.0, + z: 2.0, + }; + let faces = marching_cubes( + 6, + 6, + 6, + min, + max, + &|x, y, z| x * x + y * y + z * z - 1.0, + 0.0, + ); + assert!(!faces.is_empty()); + + let glb = faces_to_glb_quantized(&faces); + assert_eq!(&glb[0..4], b"glTF"); + let version = u32::from_le_bytes([glb[4], glb[5], glb[6], glb[7]]); + assert_eq!(version, 2); + let total = u32::from_le_bytes([glb[8], glb[9], glb[10], glb[11]]); + assert_eq!(total as usize, glb.len()); + assert_eq!(glb.len() % 4, 0); + + // Verify extension in JSON + let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize; + let json = std::str::from_utf8(&glb[20..20 + json_len]).unwrap().trim(); + assert!(json.contains("KHR_mesh_quantization")); + assert!(json.contains("\"componentType\":5122")); // SHORT + assert!(json.contains("\"extensionsRequired\"")); + } + + #[test] + fn test_quantized_has_node_matrix() { + let glb = faces_to_glb_quantized(&[test_face()]); + let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize; + let json = std::str::from_utf8(&glb[20..20 + json_len]).unwrap().trim(); + // Node should have a matrix for dequantization + assert!(json.contains("\"matrix\"")); + } + + #[test] + fn test_quantized_i16_min_max() { + let glb = faces_to_glb_quantized(&[test_face()]); + let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize; + let json = std::str::from_utf8(&glb[20..20 + json_len]).unwrap().trim(); + // i16 range: [-32767, 32767] + assert!(json.contains("\"max\":[32767,32767,32767]")); + assert!(json.contains("\"min\":[-32767,-32767,-32767]")); + } + + #[test] + fn test_quantized_binary_buffer_size() { + // 3 vertices * 3 components * 2 bytes (i16) = 18 bytes, padded to 20 + // 3 indices * 4 bytes (u32) = 12 bytes + // total binary = 32 bytes + let glb = faces_to_glb_quantized(&[test_face()]); + + let json_len = u32::from_le_bytes([glb[12], glb[13], glb[14], glb[15]]) as usize; + let bin_offset = 20 + json_len; + let bin_len = u32::from_le_bytes([ + glb[bin_offset], + glb[bin_offset + 1], + glb[bin_offset + 2], + glb[bin_offset + 3], + ]) as usize; + // pos: 3*3*2=18 padded to 20, idx: 3*4=12, total=32 + assert_eq!(bin_len, 32); + } + + #[test] + fn test_quantized_vs_regular_both_valid() { + let faces = vec![ + Face { + a: Point3D { + index: 0, + x: -5.0, + y: -5.0, + z: -5.0, + }, + b: Point3D { + index: 1, + x: 5.0, + y: -5.0, + z: -5.0, + }, + c: Point3D { + index: 2, + x: 0.0, + y: 5.0, + z: -5.0, + }, + }, + Face { + a: Point3D { + index: 0, + x: -5.0, + y: -5.0, + z: -5.0, + }, + b: Point3D { + index: 2, + x: 0.0, + y: 5.0, + z: -5.0, + }, + c: Point3D { + index: 3, + x: 0.0, + y: 0.0, + z: 5.0, + }, + }, + ]; + let regular = faces_to_glb(&faces); + let quantized = faces_to_glb_quantized(&faces); + + // Both are valid GLB + assert_eq!(®ular[0..4], b"glTF"); + assert_eq!(&quantized[0..4], b"glTF"); + assert_eq!(regular.len() % 4, 0); + assert_eq!(quantized.len() % 4, 0); + + // Lengths match declared size + let reg_total = u32::from_le_bytes([regular[8], regular[9], regular[10], regular[11]]); + assert_eq!(reg_total as usize, regular.len()); + let q_total = + u32::from_le_bytes([quantized[8], quantized[9], quantized[10], quantized[11]]); + assert_eq!(q_total as usize, quantized.len()); + } } diff --git a/src/export/obj.rs b/src/export/obj.rs index c5d4ee3..0e77078 100644 --- a/src/export/obj.rs +++ b/src/export/obj.rs @@ -251,4 +251,142 @@ mod tests { assert!(result.contains("v 7 8 9")); assert!(result.contains("f 1 2 3")); } + + #[test] + fn test_obj_from_marching_cubes_sphere() { + use crate::marching_cubes::marching_cubes; + let min = Point3D { + index: 0, + x: -2.0, + y: -2.0, + z: -2.0, + }; + let max = Point3D { + index: 0, + x: 2.0, + y: 2.0, + z: 2.0, + }; + let faces = marching_cubes( + 6, + 6, + 6, + min, + max, + &|x, y, z| x * x + y * y + z * z - 1.0, + 0.0, + ); + assert!(!faces.is_empty()); + + let obj = faces_to_obj(&faces); + let vertex_lines: Vec<&str> = obj.lines().filter(|l| l.starts_with("v ")).collect(); + let face_lines: Vec<&str> = obj.lines().filter(|l| l.starts_with("f ")).collect(); + assert!(!vertex_lines.is_empty()); + assert_eq!(face_lines.len(), faces.len()); + + // Every face index should be valid (1-based, within vertex count) + let num_verts = vertex_lines.len(); + for line in &face_lines { + let indices: Vec = line[2..] + .split_whitespace() + .map(|s| s.parse::().unwrap()) + .collect(); + assert_eq!(indices.len(), 3); + for idx in indices { + assert!(idx >= 1 && idx <= num_verts); + } + } + } + + #[test] + fn test_obj_face_indices_one_based() { + let face = Face { + a: Point3D { + index: 5, + x: 0.0, + y: 0.0, + z: 0.0, + }, + b: Point3D { + index: 10, + x: 1.0, + y: 0.0, + z: 0.0, + }, + c: Point3D { + index: 15, + x: 0.0, + y: 1.0, + z: 0.0, + }, + }; + let obj = faces_to_obj(&[face]); + // Indices should be 1, 2, 3 regardless of Point3D.index values + assert!(obj.contains("f 1 2 3")); + } + + #[test] + fn test_obj_tetrahedra_surface_extraction() { + // A single tetrahedron should produce 4 faces and 4 vertices in OBJ + let tet = Tetrahedron { + a: Point3D { + index: 0, + x: 0.0, + y: 0.0, + z: 0.0, + }, + b: Point3D { + index: 1, + x: 1.0, + y: 0.0, + z: 0.0, + }, + c: Point3D { + index: 2, + x: 0.0, + y: 1.0, + z: 0.0, + }, + d: Point3D { + index: 3, + x: 0.0, + y: 0.0, + z: 1.0, + }, + }; + let obj = tetrahedra_to_obj(&[tet]); + let v_count = obj.lines().filter(|l| l.starts_with("v ")).count(); + let f_count = obj.lines().filter(|l| l.starts_with("f ")).count(); + assert_eq!(v_count, 4); + assert_eq!(f_count, 4); + } + + #[test] + fn test_obj_vertex_precision() { + let face = Face { + a: Point3D { + index: 0, + x: 0.123456789, + y: -0.987654321, + z: 42.0, + }, + b: Point3D { + index: 1, + x: 1.0, + y: 0.0, + z: 0.0, + }, + c: Point3D { + index: 2, + x: 0.0, + y: 1.0, + z: 0.0, + }, + }; + let obj = faces_to_obj(&[face]); + // Verify coordinates appear in the output + assert!(obj.contains("0.123456789")); + assert!(obj.contains("-0.987654321")); + assert!(obj.contains("42")); + } } diff --git a/src/export/stl.rs b/src/export/stl.rs index 5fe30e9..a945372 100644 --- a/src/export/stl.rs +++ b/src/export/stl.rs @@ -294,4 +294,119 @@ mod tests { // 2 tets * 4 faces = 8 total, minus 2 (shared face counted in both) = 6 assert_eq!(surface.len(), 6); } + + #[test] + fn test_stl_from_marching_cubes_sphere() { + use crate::marching_cubes::marching_cubes; + let min = Point3D { + index: 0, + x: -2.0, + y: -2.0, + z: -2.0, + }; + let max = Point3D { + index: 0, + x: 2.0, + y: 2.0, + z: 2.0, + }; + let faces = marching_cubes( + 6, + 6, + 6, + min, + max, + &|x, y, z| x * x + y * y + z * z - 1.0, + 0.0, + ); + assert!(!faces.is_empty()); + + let stl = faces_to_stl(&faces, "sphere"); + assert!(stl.starts_with("solid sphere\n")); + assert!(stl.ends_with("endsolid sphere\n")); + let facet_count = stl.matches("facet normal").count(); + assert_eq!(facet_count, faces.len()); + let vertex_count = stl.matches("vertex ").count(); + assert_eq!(vertex_count, faces.len() * 3); + let loop_count = stl.matches("outer loop").count(); + assert_eq!(loop_count, faces.len()); + } + + #[test] + fn test_stl_normal_direction_xz_plane() { + let face = Face { + a: Point3D { + index: 0, + x: 0.0, + y: 0.0, + z: 0.0, + }, + b: Point3D { + index: 1, + x: 1.0, + y: 0.0, + z: 0.0, + }, + c: Point3D { + index: 2, + x: 0.0, + y: 0.0, + z: 1.0, + }, + }; + let result = faces_to_stl(&[face], "xz"); + // Cross product of (1,0,0)x(0,0,1) = (0,-1,0) + assert!(result.contains("facet normal 0 -1 0")); + } + + #[test] + fn test_stl_face_structure_valid() { + let tet = single_tet(); + let stl = tetrahedra_to_stl(&[tet], "valid"); + // Every "facet normal" must be followed by "outer loop", 3 vertices, "endloop", "endfacet" + let lines: Vec<&str> = stl.lines().collect(); + let mut i = 0; + while i < lines.len() { + let trimmed = lines[i].trim(); + if trimmed.starts_with("facet normal") { + assert_eq!(lines[i + 1].trim(), "outer loop"); + assert!(lines[i + 2].trim().starts_with("vertex ")); + assert!(lines[i + 3].trim().starts_with("vertex ")); + assert!(lines[i + 4].trim().starts_with("vertex ")); + assert_eq!(lines[i + 5].trim(), "endloop"); + assert_eq!(lines[i + 6].trim(), "endfacet"); + i += 7; + } else { + i += 1; + } + } + } + + #[test] + fn test_stl_vertex_coordinates_preserved() { + let face = Face { + a: Point3D { + index: 0, + x: 1.5, + y: 2.5, + z: 3.5, + }, + b: Point3D { + index: 1, + x: -0.5, + y: 0.0, + z: 10.0, + }, + c: Point3D { + index: 2, + x: 0.0, + y: -5.0, + z: 0.0, + }, + }; + let stl = faces_to_stl(&[face], "coords"); + assert!(stl.contains("vertex 1.5 2.5 3.5")); + assert!(stl.contains("vertex -0.5 0 10")); + assert!(stl.contains("vertex 0 -5 0")); + } } diff --git a/src/export/vtk.rs b/src/export/vtk.rs index d2f0c56..16a94af 100644 --- a/src/export/vtk.rs +++ b/src/export/vtk.rs @@ -236,4 +236,117 @@ mod tests { assert!(result.contains("7 8 9")); assert!(result.contains("0 0 0")); } + + #[test] + fn test_vtk_from_voxel_mesh() { + use crate::voxel_mesh::voxel_mesh; + let min = Point3D { + index: 0, + x: 0.0, + y: 0.0, + z: 0.0, + }; + let max = Point3D { + index: 0, + x: 1.0, + y: 1.0, + z: 1.0, + }; + let tets = voxel_mesh(min, max, 2, 2, 2, &|_| true); + assert!(!tets.is_empty()); + + let vtk = tetrahedra_to_vtk(&tets, "voxel_test"); + assert!(vtk.contains("# vtk DataFile Version 3.0")); + assert!(vtk.contains("voxel_test")); + + // Parse point count + let points_line = vtk.lines().find(|l| l.starts_with("POINTS ")).unwrap(); + let num_points: usize = points_line + .split_whitespace() + .nth(1) + .unwrap() + .parse() + .unwrap(); + assert!(num_points > 0); + + // Parse cell count + let cells_line = vtk.lines().find(|l| l.starts_with("CELLS ")).unwrap(); + let num_cells: usize = cells_line + .split_whitespace() + .nth(1) + .unwrap() + .parse() + .unwrap(); + assert_eq!(num_cells, tets.len()); + + // All cell types should be 10 (VTK_TETRA) + let cell_types_idx = vtk + .lines() + .position(|l| l.starts_with("CELL_TYPES")) + .unwrap(); + let cell_type_lines: Vec<&str> = vtk + .lines() + .skip(cell_types_idx + 1) + .take(num_cells) + .collect(); + for line in cell_type_lines { + assert_eq!(line.trim(), "10"); + } + } + + #[test] + fn test_vtk_cell_indices_valid() { + let tet = single_tet(); + let vtk = tetrahedra_to_vtk(&[tet], "idx_check"); + + // Parse number of points + let points_line = vtk.lines().find(|l| l.starts_with("POINTS ")).unwrap(); + let num_points: usize = points_line + .split_whitespace() + .nth(1) + .unwrap() + .parse() + .unwrap(); + + // Find cell lines and verify all indices are in range + let cells_idx = vtk.lines().position(|l| l.starts_with("CELLS ")).unwrap(); + let cells_line = vtk.lines().nth(cells_idx).unwrap(); + let num_cells: usize = cells_line + .split_whitespace() + .nth(1) + .unwrap() + .parse() + .unwrap(); + + for line in vtk.lines().skip(cells_idx + 1).take(num_cells) { + let parts: Vec = line + .split_whitespace() + .map(|s| s.parse::().unwrap()) + .collect(); + assert_eq!(parts[0], 4); // 4 vertices per tet + for &idx in &parts[1..] { + assert!(idx < num_points); + } + } + } + + #[test] + fn test_vtk_two_tets_shared_vertices() { + let p = |i: i64, x: f64, y: f64, z: f64| Point3D { index: i, x, y, z }; + let tet1 = Tetrahedron { + a: p(0, 0.0, 0.0, 0.0), + b: p(1, 1.0, 0.0, 0.0), + c: p(2, 0.0, 1.0, 0.0), + d: p(3, 0.0, 0.0, 1.0), + }; + let tet2 = Tetrahedron { + a: p(1, 1.0, 0.0, 0.0), + b: p(2, 0.0, 1.0, 0.0), + c: p(3, 0.0, 0.0, 1.0), + d: p(4, 1.0, 1.0, 1.0), + }; + let vtk = tetrahedra_to_vtk(&[tet1, tet2], "two_tets"); + assert!(vtk.contains("POINTS 5 double")); + assert!(vtk.contains("CELLS 2 10")); + } }