fix(step): place each exported face on its triangle's own plane

build_step took the PLANE 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, so the plane missed
two of the face's corners and the first edge was no longer perpendicular
to the placement axis.

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 <[email protected]>
This commit is contained in:
kevin9327andClaude Opus 5 committed 2026-09-14 20:04:00 +09:00
1 parent cba64631b8
commit bc3e63ff53
1 file changed
+68 -7
+68 -7
View File
@@ -48,15 +48,19 @@ pub fn build_step(meshes: &[&MeshModel]) -> Option<String> {
let a = point(i0);
let b = point(i1);
let c = point(i2);
let n = if !normals.is_empty() && i0 < normals.len() {
// The face is placed on the triangle's own plane. 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 n = if len <= f64::EPSILON && i0 < normals.len() {
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(f64::EPSILON);
let len = len.max(f64::EPSILON);
[(nx / len) as f32, (ny / len) as f32, (nz / len) as f32]
};
tris.push(Tri { v: [a, b, c], n });
@@ -330,4 +334,61 @@ mod tests {
let step = build_step(&[&mesh]).expect("step");
assert!(step.contains("CARTESIAN_POINT('',(1.500000,2.000000,0.000000));"));
}
/// The direction each face's PLANE is placed with, read back through its
/// AXIS2_PLACEMENT_3D. The export writes one face per triangle.
fn plane_normals(step: &str) -> Vec<[f64; 3]> {
let direction = |id: &str| -> [f64; 3] {
let prefix = format!("{id} = DIRECTION('',(");
let line = step
.lines()
.find(|line| line.starts_with(&prefix))
.unwrap_or_else(|| panic!("no DIRECTION {id}"));
let values: Vec<f64> = line[prefix.len()..]
.trim_end_matches("));")
.split(',')
.map(|value| value.parse().expect("number"))
.collect();
[values[0], values[1], values[2]]
};
step.lines()
.filter_map(|line| line.split_once(" = AXIS2_PLACEMENT_3D('',"))
.map(|(_, refs)| {
let refs: Vec<&str> = refs.trim_end_matches(");").split(',').collect();
direction(refs[1])
})
.collect()
}
/// A curved surface shares smoothed normals across its facets, so a
/// vertex normal leans away from the triangle. The face's PLANE has to be
/// the triangle's own plane, or its corners do not lie on it.
#[test]
fn a_face_plane_follows_the_triangle_not_its_vertex_normal() {
let mut mesh = triangle(
vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
Vec::new(),
);
let leaning = [
std::f32::consts::FRAC_1_SQRT_2,
0.0,
std::f32::consts::FRAC_1_SQRT_2,
];
mesh.normals = vec![leaning; 3];
let step = build_step(&[&mesh]).expect("step");
assert_eq!(plane_normals(&step), vec![[0.0, 0.0, 1.0]]);
}
/// A triangle with no area has no plane of its own, so it keeps the
/// normal the mesh gives it rather than a zero direction.
#[test]
fn a_degenerate_triangle_keeps_its_vertex_normal() {
let mut mesh = triangle(
vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
Vec::new(),
);
mesh.normals = vec![[0.0, 1.0, 0.0]; 3];
let step = build_step(&[&mesh]).expect("step");
assert_eq!(plane_normals(&step), vec![[0.0, 1.0, 0.0]]);
}
}