// STL binary export — converts all tessellated MeshModels in the scene to a // single binary STL file. // // Binary STL format: // 80-byte header // 4-byte triangle count (u32 LE) // Per triangle (50 bytes): // 3 × f32 normal // 3 × 3 × f32 vertices // 2-byte attribute (0) use std::io::Write; use crate::scene::model::mesh_model::MeshModel; /// Build a binary STL byte buffer from a slice of mesh models. /// Returns `None` if there are no triangles to export. pub fn build_stl(meshes: &[&MeshModel]) -> Option> { // Collect all triangles. struct Tri { normal: [f32; 3], v: [[f32; 3]; 3], } let mut tris: Vec = Vec::new(); for mesh in meshes { let verts = &mesh.verts; let idx = &mesh.indices; let n_tri = idx.len() / 3; for t in 0..n_tri { let i0 = idx[t * 3] as usize; let i1 = idx[t * 3 + 1] as usize; let i2 = idx[t * 3 + 2] as usize; if i0 >= verts.len() || i1 >= verts.len() || i2 >= verts.len() { continue; } let a = verts[i0]; let b = verts[i1]; let c = verts[i2]; // The facet normal is the triangle's own. A smoothed vertex normal // leans off it, so only a triangle whose cross product is zero // falls back to the normal the mesh gives it. Small facets have a // tiny cross product but still a normal of their own. let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; let nx = ab[1] * ac[2] - ab[2] * ac[1]; let ny = ab[2] * ac[0] - ab[0] * ac[2]; let nz = ab[0] * ac[1] - ab[1] * ac[0]; let len = nx.hypot(ny).hypot(nz); let normal = if len == 0.0 && i0 < mesh.normals.len() { mesh.normals[i0] } else { let len = len.max(f32::MIN_POSITIVE); [nx / len, ny / len, nz / len] }; tris.push(Tri { normal, v: [a, b, c], }); } } if tris.is_empty() { return None; } let mut buf: Vec = Vec::with_capacity(84 + tris.len() * 50); // 80-byte header. let mut header = [0u8; 80]; let title = b"Open CAD Studio STL export"; header[..title.len()].copy_from_slice(title); buf.extend_from_slice(&header); // Triangle count. buf.extend_from_slice(&(tris.len() as u32).to_le_bytes()); for tri in &tris { // Normal. for &f in &tri.normal { buf.write_all(&f.to_le_bytes()).ok()?; } // Vertices. for v in &tri.v { for &f in v { buf.write_all(&f.to_le_bytes()).ok()?; } } // Attribute byte count = 0. buf.extend_from_slice(&0u16.to_le_bytes()); } Some(buf) } /// Web: no save dialog — the export continues with a virtual filename and /// the generated bytes are delivered as a browser download. #[cfg(target_arch = "wasm32")] pub async fn pick_stl_path_owned(stem: String) -> Option { Some(std::path::PathBuf::from(format!("{stem}.stl"))) } #[cfg(test)] mod tests { use super::build_stl; use crate::scene::model::mesh_model::MeshModel; fn triangle(verts: Vec<[f32; 3]>, normals: Vec<[f32; 3]>) -> MeshModel { MeshModel { name: String::new(), verts, verts_low: Vec::new(), normals, indices: vec![0, 1, 2], triangle_material_handles: Vec::new(), triangle_colors: Vec::new(), color: [1.0; 4], selected: false, } } /// The first facet's normal: the 12 bytes after the 80-byte header and /// the 4-byte triangle count. fn first_facet_normal(stl: &[u8]) -> [f32; 3] { [0, 1, 2].map(|k| { let at = 84 + 4 * k; f32::from_le_bytes(stl[at..at + 4].try_into().expect("4 bytes")) }) } /// A curved surface shares smoothed normals across its facets, so a /// vertex normal leans away from the triangle. STL stores the facet's /// own normal. #[test] fn the_facet_normal_is_the_triangles_own() { let leaning = [ std::f32::consts::FRAC_1_SQRT_2, 0.0, std::f32::consts::FRAC_1_SQRT_2, ]; let mesh = triangle( vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], vec![leaning; 3], ); let stl = build_stl(&[&mesh]).expect("stl"); assert_eq!(first_facet_normal(&stl), [0.0, 0.0, 1.0]); } /// A small facet still has a normal of its own. Its cross product is tiny /// (1e-8 here), far below f32::EPSILON, but it is not zero. #[test] fn a_small_facet_keeps_its_own_normal() { let leaning = [ std::f32::consts::FRAC_1_SQRT_2, 0.0, std::f32::consts::FRAC_1_SQRT_2, ]; let mesh = triangle( vec![[0.0, 0.0, 0.0], [1e-4, 0.0, 0.0], [0.0, 1e-4, 0.0]], vec![leaning; 3], ); let stl = build_stl(&[&mesh]).expect("stl"); assert_eq!(first_facet_normal(&stl), [0.0, 0.0, 1.0]); } #[test] fn a_facet_whose_squared_cross_product_underflows_keeps_its_own_normal() { let mesh = triangle( vec![[0.0, 0.0, 0.0], [1.0e-12, 0.0, 0.0], [0.0, 1.0e-12, 0.0]], vec![[0.0, 1.0, 0.0]; 3], ); let stl = build_stl(&[&mesh]).expect("stl"); assert_eq!(first_facet_normal(&stl), [0.0, 0.0, 1.0]); } /// A facet with no area has no normal of its own, so it keeps the one /// the mesh gives it rather than a zero vector. #[test] fn a_degenerate_facet_keeps_its_vertex_normal() { let mesh = triangle( vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], vec![[0.0, 1.0, 0.0]; 3], ); let stl = build_stl(&[&mesh]).expect("stl"); assert_eq!(first_facet_normal(&stl), [0.0, 1.0, 0.0]); } }