From 421f6e169ba1a2902f63eab5b85ddc795400938d Mon Sep 17 00:00:00 2001 From: Bas <16050771+Basnederveen@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:38:40 +0100 Subject: [PATCH] fix(tessellation): a triangle seen edge-on from its surface turns like its face Earcut can clip an ear from samples along one straight trim edge when rounding puts one of them off the line in UV. On a cylinder those samples lie on a circle, so the triangle's normal is perpendicular to the surface normal and the orientation test found exactly zero. Earcut and refinement give one face a single winding, so such a triangle now takes the flip of the face's other triangles. Co-Authored-By: Claude Opus 5.5 --- .../tessellation/tests/edge_on_triangles.rs | 55 +++++++++++++++++++ .../src/tessellation/tests/mod.rs | 1 + .../src/tessellation/trimmed_face.rs | 54 ++++++++++++++---- 3 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 main/opengeometry/src/tessellation/tests/edge_on_triangles.rs diff --git a/main/opengeometry/src/tessellation/tests/edge_on_triangles.rs b/main/opengeometry/src/tessellation/tests/edge_on_triangles.rs new file mode 100644 index 00000000..bedd70cb --- /dev/null +++ b/main/opengeometry/src/tessellation/tests/edge_on_triangles.rs @@ -0,0 +1,55 @@ +use super::super::mesh::Tessellation; +use super::super::trimmed_face::general_curved_trimmed_face; +use crate::brep::{fine_accuracy, Frame3, Surface, SurfaceGeometry}; +use crate::primitives; +use std::collections::BTreeSet; + +const SLIVER: [[f64; 2]; 6] = [ + [0.0, 0.4315522348698053], + [-2.4066236448435014e-16, 0.0], + [0.0970789271307487, 0.0], + [0.1941578542615101, 2.2648549702353193e-14], + [0.12598686852943008, 0.13848252444618137], + [0.061224770648327764, 0.2824284296054027], +]; + +#[test] +fn an_ear_of_end_samples_rounded_off_their_line_turns_like_its_face() { + let body = + primitives::cylinder("c".into(), Frame3::IDENTITY, 3.35, 1.0, fine_accuracy()).unwrap(); + let face = body + .topology + .faces + .iter() + .find(|face| { + matches!( + body.geometry.surfaces[face.surface as usize], + SurfaceGeometry::Cylinder { .. } + ) + }) + .unwrap(); + let surface = body.geometry.surface(face.surface).unwrap(); + let ring = SLIVER + .into_iter() + .map(|uv| { + let on_end = [uv[0], if uv[1].abs() < 1e-12 { 0.0 } else { uv[1] }]; + (uv, surface.point_at(on_end).unwrap()) + }) + .collect::>(); + let mut mesh = Tessellation::new(0, 0.02); + general_curved_trimmed_face(&body, face, [0, 0], &[ring], 0.02, &mut mesh, 1000).unwrap(); + let mut directed = BTreeSet::new(); + for triangle in mesh.indices.chunks_exact(3) { + for edge in [ + (triangle[0], triangle[1]), + (triangle[1], triangle[2]), + (triangle[2], triangle[0]), + ] { + assert!( + directed.insert(edge), + "edge {edge:?} runs the same way twice" + ); + } + } + assert!(mesh.indices.len() >= 12); +} diff --git a/main/opengeometry/src/tessellation/tests/mod.rs b/main/opengeometry/src/tessellation/tests/mod.rs index 404d5492..a2055503 100644 --- a/main/opengeometry/src/tessellation/tests/mod.rs +++ b/main/opengeometry/src/tessellation/tests/mod.rs @@ -1,4 +1,5 @@ mod display; +mod edge_on_triangles; mod seams; mod short_loops; mod smooth_edges; diff --git a/main/opengeometry/src/tessellation/trimmed_face.rs b/main/opengeometry/src/tessellation/trimmed_face.rs index acb8528a..553edfb2 100644 --- a/main/opengeometry/src/tessellation/trimmed_face.rs +++ b/main/opengeometry/src/tessellation/trimmed_face.rs @@ -5,7 +5,7 @@ use super::trim::{ }; use crate::brep::{BrepEnvelope, Face, GeometryError, Surface, SurfaceGeometry}; use crate::geom2d::{point_in_ring2, Pt2}; -use crate::math::{scale, Interval, Point3}; +use crate::math::{cross, dot, norm, scale, sub, Interval, Point3}; use std::collections::{BTreeSet, HashMap}; const COUNT_ROUNDING: f64 = 1e-9; @@ -194,7 +194,7 @@ fn shift_periodic_holes( Ok(()) } -fn general_curved_trimmed_face( +pub(super) fn general_curved_trimmed_face( brep: &BrepEnvelope, face: &Face, _desired: [usize; 2], @@ -252,17 +252,47 @@ fn general_curved_trimmed_face( let normal = trim_normal(brep, face, surface, *coordinate)?; mesh.vertex(point, normal)?; } - for triangle in triangles { - let center = std::array::from_fn(|axis| { - (uv[triangle[0]][axis] + uv[triangle[1]][axis] + uv[triangle[2]][axis]) / 3.0 - }); + let triangles = triangles + .into_iter() + .map(|triangle| { + ( + triangle.map(|index| (start + index) as u32), + triangle.map(|index| uv[index]), + ) + }) + .collect::>(); + push_triangles_with_one_winding(brep, face, surface, &triangles, mesh, max_triangles) +} + +fn push_triangles_with_one_winding( + brep: &BrepEnvelope, + face: &Face, + surface: &SurfaceGeometry, + triangles: &[([u32; 3], [[f64; 2]; 3])], + mesh: &mut Tessellation, + max_triangles: usize, +) -> Result<(), GeometryError> { + let mut oriented = Vec::with_capacity(triangles.len()); + let mut flip = None; + for (ids, coordinates) in triangles { + let center = + std::array::from_fn(|axis| coordinates.iter().map(|uv| uv[axis]).sum::() / 3.0); let normal = trim_normal(brep, face, surface, center)?; - mesh.triangle( - triangle.map(|index| (start + index) as u32), - face.id, - normal, - max_triangles, - )?; + let [a, b, c] = ids.map(|id| mesh.point(id)); + let direction = cross(sub(b, a), sub(c, a)); + let along = dot(direction, normal); + let edge_on = along.abs() <= 1e-6 * norm(direction); + if !edge_on && flip.is_none() { + flip = Some(along < 0.0); + } + oriented.push((*ids, normal, direction, edge_on)); + } + for (ids, normal, direction, edge_on) in oriented { + let normal = match (edge_on, flip) { + (true, Some(flip)) => scale(direction, if flip { -1.0 } else { 1.0 }), + _ => normal, + }; + mesh.triangle(ids, face.id, normal, max_triangles)?; } Ok(()) }