From c07fe1b2d88960de51dac149470c82a3465318ca Mon Sep 17 00:00:00 2001 From: Bas <16050771+Basnederveen@users.noreply.github.com> Date: Sun, 1 Nov 2026 11:37:29 +0100 Subject: [PATCH] fix(tessellation): small holes in curved faces at coarse deflections; trim runs off by rounding A hole through a curved wall is bounded by intersection curves; at a deflection on the order of the hole each got one segment, a two-sample trim loop: "curved trim loop requires at least four samples". The short loop raise (planar loops only) now also raises a curved face's trim loop to four samples through its curved edges, intersection curves included. Planar loops keep the old rule. Samples along an axis-parallel pcurve can differ by a few ulps from the corner vertex next to them, whose UV comes from the neighbouring pcurve. Earcut then cuts a near-zero-area ear along the run whose 3D points lie on the edge, so its normal is perpendicular to the surface: "triangle orientation could not be resolved". Ring coordinates within 64 eps of the previous sample's are snapped to it; 3D points are unchanged. The orientation error and the refinement-triangle limit name the face. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VFZvqx1RmXzPtbL9VXP4H7 --- .../src/tessellation/loop_minimum.rs | 58 ++++---- main/opengeometry/src/tessellation/mesh.rs | 6 +- .../src/tessellation/tessellate.rs | 4 +- .../src/tessellation/tests/curved_trims.rs | 136 ++++++++++++++++++ .../src/tessellation/tests/mod.rs | 1 + .../src/tessellation/trimmed_face.rs | 26 +++- 6 files changed, 198 insertions(+), 33 deletions(-) create mode 100644 main/opengeometry/src/tessellation/tests/curved_trims.rs diff --git a/main/opengeometry/src/tessellation/loop_minimum.rs b/main/opengeometry/src/tessellation/loop_minimum.rs index 0c7717c5..8b379bc9 100644 --- a/main/opengeometry/src/tessellation/loop_minimum.rs +++ b/main/opengeometry/src/tessellation/loop_minimum.rs @@ -1,17 +1,24 @@ use crate::brep::{BrepEnvelope, CurveGeometry, EdgeGeometry, GeometryError, SurfaceGeometry}; -pub(super) fn raise_short_planar_loops( +const PLANAR_LOOP_POINTS: usize = 3; +const CURVED_TRIM_SAMPLES: usize = 4; + +pub(super) fn raise_short_loops( brep: &BrepEnvelope, counts: &mut [usize], ) -> Result<(), GeometryError> { - for minimum in [2, 3] { - let points = planar_loop_points(brep, counts)?; + for minimum in [2, 3, 4] { + let points = loop_points(brep, counts)?; for h in &brep.topology.halfedges { - let short = h - .loop_ref - .and_then(|id| points[id as usize]) - .is_some_and(|n| n < 3); - if short && conic(brep, h.edge) { + let Some((total, planar)) = h.loop_ref.map(|id| points[id as usize]) else { + continue; + }; + let short = if planar { + minimum <= PLANAR_LOOP_POINTS && total < PLANAR_LOOP_POINTS + } else { + total < CURVED_TRIM_SAMPLES + }; + if short && raisable(brep, h.edge, planar) { counts[h.edge as usize] = counts[h.edge as usize].max(minimum); } } @@ -19,33 +26,34 @@ pub(super) fn raise_short_planar_loops( Ok(()) } -fn planar_loop_points( - brep: &BrepEnvelope, - counts: &[usize], -) -> Result>, GeometryError> { +fn loop_points(brep: &BrepEnvelope, counts: &[usize]) -> Result, GeometryError> { let mut points = Vec::with_capacity(brep.topology.loops.len()); for face_loop in &brep.topology.loops { let face = &brep.topology.faces[face_loop.face_ref as usize]; let surface = brep.geometry.surface(face.surface)?; - points.push(matches!(surface, SurfaceGeometry::Plane { .. }).then_some(0)); + points.push((0, matches!(surface, SurfaceGeometry::Plane { .. }))); } for h in &brep.topology.halfedges { - let edge = &brep.topology.edges[h.edge as usize]; - if let (Some(id), EdgeGeometry::Curve { .. }) = (h.loop_ref, &edge.geometry) { - if let Some(total) = &mut points[id as usize] { - *total += counts[h.edge as usize]; - } + let Some(id) = h.loop_ref else { + continue; + }; + let (total, planar) = &mut points[id as usize]; + match brep.topology.edges[h.edge as usize].geometry { + EdgeGeometry::Curve { .. } => *total += counts[h.edge as usize], + EdgeGeometry::Collapsed { .. } if !*planar => *total += 1, + EdgeGeometry::Collapsed { .. } => {} } } Ok(points) } -fn conic(brep: &BrepEnvelope, edge: u32) -> bool { - match brep.topology.edges[edge as usize].geometry { - EdgeGeometry::Curve { curve, .. } => matches!( - brep.geometry.curves[curve as usize], - CurveGeometry::Circle { .. } | CurveGeometry::Ellipse { .. } - ), - EdgeGeometry::Collapsed { .. } => false, +fn raisable(brep: &BrepEnvelope, edge: u32, planar: bool) -> bool { + let EdgeGeometry::Curve { curve, .. } = brep.topology.edges[edge as usize].geometry else { + return false; + }; + match brep.geometry.curves[curve as usize] { + CurveGeometry::Circle { .. } | CurveGeometry::Ellipse { .. } => true, + CurveGeometry::Intersection { .. } => !planar, + CurveGeometry::Line { .. } => false, } } diff --git a/main/opengeometry/src/tessellation/mesh.rs b/main/opengeometry/src/tessellation/mesh.rs index 6544344d..97bbaa99 100644 --- a/main/opengeometry/src/tessellation/mesh.rs +++ b/main/opengeometry/src/tessellation/mesh.rs @@ -69,9 +69,9 @@ impl Tessellation { } let orientation = dot(direction, normal); if !orientation.is_finite() || orientation == 0.0 { - return Err(GeometryError::UnresolvedTessellation( - "triangle orientation could not be resolved".into(), - )); + return Err(GeometryError::UnresolvedTessellation(format!( + "triangle orientation could not be resolved on face {face}" + ))); } if orientation < 0.0 { ids.swap(1, 2); diff --git a/main/opengeometry/src/tessellation/tessellate.rs b/main/opengeometry/src/tessellation/tessellate.rs index c433be06..cdc09d0c 100644 --- a/main/opengeometry/src/tessellation/tessellate.rs +++ b/main/opengeometry/src/tessellation/tessellate.rs @@ -2,7 +2,7 @@ use super::curved_face::curved_face; use super::deviation::edge_count; use super::edges::{sample_edge, synchronize_edge_samples}; use super::grid::{grid_size, rectangle, Rect}; -use super::loop_minimum::raise_short_planar_loops; +use super::loop_minimum::raise_short_loops; use super::mesh::Tessellation; use super::planar_face::planar_face; use super::trimmed_face::{curved_trimmed_face, FaceGrid}; @@ -39,7 +39,7 @@ pub fn tessellate( for edge in &brep.topology.edges { counts.push(edge_count(brep, edge.id, error, max_triangles)?); } - raise_short_planar_loops(brep, &mut counts)?; + raise_short_loops(brep, &mut counts)?; let mut grids = Vec::with_capacity(brep.topology.faces.len()); for face in &brep.topology.faces { let surface = brep.geometry.surface(face.surface)?; diff --git a/main/opengeometry/src/tessellation/tests/curved_trims.rs b/main/opengeometry/src/tessellation/tests/curved_trims.rs new file mode 100644 index 00000000..96b67644 --- /dev/null +++ b/main/opengeometry/src/tessellation/tests/curved_trims.rs @@ -0,0 +1,136 @@ +use crate::brep::{fine_accuracy, BrepEnvelope, Frame3, PcurveGeometry, SurfaceGeometry}; +use crate::math::cross; +use crate::operations::modifying::boolean::{boolean_brep, BooleanOp}; +use crate::primitives::{self, ProfileEdge}; +use crate::tessellation::tessellate; +use std::f64::consts::FRAC_PI_2; + +const WIDTH: f64 = 100.0; +const OUTER: f64 = 3.0; + +fn quarter_tube() -> BrepEnvelope { + let inner = 2.0; + primitives::arc_edged_extrusion( + "quarter-tube".into(), + Frame3::IDENTITY, + vec![ + ProfileEdge::Line { + from: [inner, 0.0], + to: [OUTER, 0.0], + }, + ProfileEdge::Arc { + center: [0.0, 0.0], + radius: OUTER, + start_angle: 0.0, + sweep_angle: FRAC_PI_2, + }, + ProfileEdge::Line { + from: [0.0, OUTER], + to: [0.0, inner], + }, + ProfileEdge::Arc { + center: [0.0, 0.0], + radius: inner, + start_angle: FRAC_PI_2, + sweep_angle: -FRAC_PI_2, + }, + ], + WIDTH, + fine_accuracy(), + ) + .unwrap() +} + +fn holed_quarter_tube() -> BrepEnvelope { + let angle = 0.7_f64; + let axis = [angle.cos(), angle.sin(), 0.0]; + let x = [0.0, 0.0, 1.0]; + let hole = primitives::cylinder( + "hole".into(), + Frame3 { + origin: [0.0, 0.0, WIDTH / 2.0], + x, + y: cross(axis, x), + z: axis, + }, + 0.4, + 10.0, + fine_accuracy(), + ) + .unwrap(); + let cut = boolean_brep( + &quarter_tube(), + &hole, + BooleanOp::Subtraction, + "holed".into(), + ) + .unwrap() + .brep; + let holed = cut + .topology + .faces + .iter() + .filter(|face| { + !face.trim.holes.is_empty() + && matches!( + cut.geometry.surfaces[face.surface as usize], + SurfaceGeometry::Cylinder { radius, .. } if radius >= 2.0 + ) + }) + .count(); + assert_eq!(holed, 2, "the hole goes through both cylinder walls"); + cut +} + +#[test] +fn a_small_hole_in_a_curved_face_tessellates_at_a_coarse_deflection() { + let body = holed_quarter_tube(); + for deflection in [2.0, 5.0] { + let mesh = tessellate(&body, deflection, 1_000_000).unwrap(); + for face in &body.topology.faces { + assert!( + mesh.triangle_face_ids.contains(&face.id), + "face {} has no triangles at deflection {deflection}", + face.key + ); + } + } +} + +#[test] +fn a_trim_run_off_its_corner_vertex_by_rounding_tessellates() { + let body = holed_quarter_tube(); + let face = body + .topology + .faces + .iter() + .find(|face| { + !face.trim.holes.is_empty() + && matches!( + body.geometry.surfaces[face.surface as usize], + SurfaceGeometry::Cylinder { radius, .. } if radius == OUTER + ) + }) + .unwrap(); + let start = body.topology.loops[face.trim.outer as usize].start_halfedge; + for steps in [1.0, 2.0, 4.0] { + let mut noisy = body.clone(); + let mut current = start; + loop { + let halfedge = &body.topology.halfedges[current as usize]; + let pcurve = halfedge.geometry_use.pcurve.unwrap() as usize; + if let PcurveGeometry::Line2 { origin, direction } = &mut noisy.geometry.pcurves[pcurve] + { + if direction[1] == 0.0 && origin[1] == WIDTH { + origin[1] = WIDTH + steps * WIDTH * f64::EPSILON; + } + } + current = halfedge.next.unwrap(); + if current == start { + break; + } + } + assert_ne!(noisy.geometry.pcurves, body.geometry.pcurves); + tessellate(&noisy, 0.01, 1_000_000).unwrap(); + } +} diff --git a/main/opengeometry/src/tessellation/tests/mod.rs b/main/opengeometry/src/tessellation/tests/mod.rs index 31eb3c09..8cfbbd4f 100644 --- a/main/opengeometry/src/tessellation/tests/mod.rs +++ b/main/opengeometry/src/tessellation/tests/mod.rs @@ -1,3 +1,4 @@ +mod curved_trims; mod display; mod seams; mod short_loops; diff --git a/main/opengeometry/src/tessellation/trimmed_face.rs b/main/opengeometry/src/tessellation/trimmed_face.rs index acb8528a..18143b5d 100644 --- a/main/opengeometry/src/tessellation/trimmed_face.rs +++ b/main/opengeometry/src/tessellation/trimmed_face.rs @@ -216,6 +216,7 @@ fn general_curved_trimmed_face( uv.push(coordinate); points.push(point); } + snap_rounding_noise(&mut uv[start..]); for offset in 0..ring.len() { let a = start + offset; let b = start + (offset + 1) % ring.len(); @@ -235,9 +236,10 @@ fn general_curved_trimmed_face( .checked_mul(4) .is_none_or(|count| count > max_triangles) { - return Err(GeometryError::LimitExceeded( - "curved trim refinement triangles".into(), - )); + return Err(GeometryError::LimitExceeded(format!( + "curved trim refinement triangles on face {} ('{}')", + face.id, face.key + ))); } let midpoints = add_edge_midpoints(&split_edges, surface, &mut uv, &mut points, boundary_count)?; @@ -267,6 +269,24 @@ fn general_curved_trimmed_face( Ok(()) } +fn snap_rounding_noise(ring: &mut [[f64; 2]]) { + let magnitude = ring + .iter() + .flatten() + .fold(1.0_f64, |maximum, value| maximum.max(value.abs())); + let tolerance = 64.0 * f64::EPSILON * magnitude; + let count = ring.len(); + for index in 0..count { + let previous = ring[(index + count - 1) % count]; + for axis in 0..2 { + let difference = (ring[index][axis] - previous[axis]).abs(); + if difference != 0.0 && difference <= tolerance { + ring[index][axis] = previous[axis]; + } + } + } +} + fn earcut_triangles(uv: &[[f64; 2]], holes: &[usize]) -> Result, GeometryError> { let flattened = uv .iter()