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] 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]); + } +}