From 6219af60edb4e80d2bf7211c9726938bb437b0ce Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:11:30 +0900 Subject: [PATCH 1/2] fix(stl): write each facet's own normal build_stl took the facet normal from the first vertex's normal whenever the mesh had normals. On smoothed meshes (tessellated curved solids, OBJ imports with vn) that normal leans off the triangle, while STL defines the facet normal as the triangle's own unit normal. Use the triangle's own normal, and keep the vertex normal only for a triangle with no area. Meshes without normals export as before. Co-Authored-By: Claude Opus 5 --- src/io/stl.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/src/io/stl.rs b/src/io/stl.rs index 99e86d0dd..6bbc0eb4f 100644 --- a/src/io/stl.rs +++ b/src/io/stl.rs @@ -39,16 +39,19 @@ pub fn build_stl(meshes: &[&MeshModel]) -> Option> { let b = verts[i1]; let c = verts[i2]; - // Compute face normal. - let normal = if !mesh.normals.is_empty() && i0 < mesh.normals.len() { + // The facet normal is the triangle's own. A smoothed vertex normal + // leans off it, so only a triangle with no area falls back to the + // normal the mesh gives it. + 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 * nx + ny * ny + nz * nz).sqrt(); + let normal = if len <= f32::EPSILON && i0 < mesh.normals.len() { mesh.normals[i0] } else { - 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 * nx + ny * ny + nz * nz).sqrt().max(f32::EPSILON); + let len = len.max(f32::EPSILON); [nx / len, ny / len, nz / len] }; @@ -91,3 +94,62 @@ pub fn build_stl(meshes: &[&MeshModel]) -> Option> { Some(buf) } + +#[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 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]); + } +} From d77a3bd774b180c1f9674b97309424f795a9eab0 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:37:49 +0900 Subject: [PATCH 2/2] fix(stl): keep the own normal of small facets The vertex-normal fallback compared the cross product's length with f32::EPSILON, an area-scale threshold, so a valid facet with 1e-4 edges (cross product 1e-8) took the leaning vertex normal again. Fall back only when the cross product is zero. Co-Authored-By: Claude Opus 5 --- src/io/stl.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/io/stl.rs b/src/io/stl.rs index 6bbc0eb4f..89aeac137 100644 --- a/src/io/stl.rs +++ b/src/io/stl.rs @@ -40,18 +40,19 @@ pub fn build_stl(meshes: &[&MeshModel]) -> Option> { let c = verts[i2]; // The facet normal is the triangle's own. A smoothed vertex normal - // leans off it, so only a triangle with no area falls back to the - // normal the mesh gives it. + // 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 * nx + ny * ny + nz * nz).sqrt(); - let normal = if len <= f32::EPSILON && i0 < mesh.normals.len() { + let normal = if len == 0.0 && i0 < mesh.normals.len() { mesh.normals[i0] } else { - let len = len.max(f32::EPSILON); + let len = len.max(f32::MIN_POSITIVE); [nx / len, ny / len, nz / len] }; @@ -141,6 +142,23 @@ mod tests { 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]); + } + /// 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]