From e16ab7c86f439a56cef7a1f574937c8b75483dbf Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Mon, 14 Sep 2026 17:09:33 -0500 Subject: [PATCH] Preserve narrow spline clearances when tessellating solid fills Recover failed small spline boundaries with the kernel chord-error sampler, using the same coordinate frame as the fill mesh. Preserve source curves and enforce recovery and total hatch vertex limits. Fixes #1268 --- src/scene/entity.rs | 35 ++++- src/scene/hatch_boundary.rs | 167 ++++++++++++++++++++ src/scene/hatch_boundary/tests.rs | 246 ++++++++++++++++++++++++++++++ src/scene/mod.rs | 1 + 4 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 src/scene/hatch_boundary.rs create mode 100644 src/scene/hatch_boundary/tests.rs diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 26d122d7f..a74c5f0ac 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1870,7 +1870,7 @@ impl Scene { .map(|point| to_xy(point[0], point[1])) .collect(); rings.push(ring); - local_rings.push(local_ring); + local_rings.push((local_ring, path)); ring_sources.push(path.boundary_handles.clone()); } @@ -1883,7 +1883,8 @@ impl Scene { let mut local_boundary = Vec::new(); let mut boundary_exterior = Vec::new(); let mut boundary_sources = Vec::new(); - for (((ring, local_ring), sources), depth) in rings + let mut spline_paths = Vec::new(); + for (((ring, (local_ring, path)), sources), depth) in rings .into_iter() .zip(local_rings) .zip(ring_sources) @@ -1901,12 +1902,14 @@ impl Scene { boundary.push([f64::NAN, f64::NAN]); local_boundary.push([f32::NAN, f32::NAN]); } + let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); - local_boundary.extend( - local_ring - .into_iter() - .map(|[x, y]| [x as f32, y as f32]), - ); + local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); + if path.edges.iter().any(|edge| { + matches!(edge, acadrust::entities::BoundaryEdge::Spline(_)) + }) { + spline_paths.push((path, local_ring, range)); + } boundary_exterior.push(depth == 0); boundary_sources.push(sources); } @@ -2095,6 +2098,24 @@ impl Scene { } } } + let project = |point: [f64; 2]| { + let [x, y] = to_xy(point[0], point[1]); + [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] + }; + for (path, ring, range) in spline_paths.into_iter().rev() { + if range.end > boundary.len() { + continue; + } + let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); + if let Some(refined) = super::hatch_boundary::refine(&ring, curves, &project) { + if boundary.len() - range.len() + refined.len() > MAX_HATCH_MODEL_VERTS { + continue; + } + boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); + local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); + } + } + let boundary_f32: Vec<[f32; 2]> = boundary .iter() .map(|&[x, y]| { diff --git a/src/scene/hatch_boundary.rs b/src/scene/hatch_boundary.rs new file mode 100644 index 000000000..daec2f0a4 --- /dev/null +++ b/src/scene/hatch_boundary.rs @@ -0,0 +1,167 @@ +use cadkernel::geom2d::{triangulate_rings, Curve}; +use cadkernel::tessellation::DEFAULT_ANGLE; + +#[cfg(test)] +mod tests; + +const MAX_RECOVERY_POINTS: usize = 512; +const MAX_EXTRA_POINTS: usize = 128; +const MAX_PASSES: usize = 16; +const MAX_INTERSECTION_CHECKS: usize = 1_000_000; + +/// Resample a crossed spline boundary without changing ordinary boundaries. +/// `project` must use the same coordinate frame as the eventual fill mesh. +pub(super) fn refine( + ring: &[[f64; 2]], + curves: impl FnOnce() -> Vec, + project: impl Fn([f64; 2]) -> [f64; 2], +) -> Option> { + if ring.len() > MAX_RECOVERY_POINTS { + return None; + } + let mut work_left = MAX_INTERSECTION_CHECKS; + if !crossings(&[ring], &project, &mut work_left)?[0] { + return None; + } + let projected = ring.iter().copied().map(&project).collect(); + if !triangulate_rings(&[projected]).1.is_empty() { + return None; + } + let limit = (ring.len() + MAX_EXTRA_POINTS).min(MAX_RECOVERY_POINTS); + let mut spans = Vec::new(); + for curve in curves() { + if let Curve::Nurbs(spline) = curve { + let (start, end) = spline.domain(); + if end <= start || spline.control_points().len() > limit { + return None; + } + for pair in spline.knots().windows(2) { + if pair[0] >= start && pair[1] <= end && pair[0] < pair[1] { + spans.push(Curve::Nurbs(spline.trimmed( + (pair[0] - start) / (end - start), + (pair[1] - start) / (end - start), + )?)); + } + } + } else { + spans.push(curve); + } + } + let mut remaining = limit; + let mut points: Vec<_> = spans + .iter() + .map(|curve| sample(curve, &mut remaining)) + .collect::>()?; + for _ in 0..MAX_PASSES { + let marked = crossings(&points, &project, &mut work_left)?; + if !marked.iter().any(|marked| *marked) { + let ring = super::entity::chain_path_edges(points); + let projected = ring.iter().copied().map(&project).collect(); + return (!triangulate_rings(&[projected]).1.is_empty()).then_some(ring); + } + let mut next = Vec::new(); + let mut sampled = Vec::new(); + let mut changed = false; + let mut remaining = limit; + for ((curve, points), marked) in spans.into_iter().zip(points).zip(marked) { + if marked { + if let Curve::Nurbs(spline) = &curve { + let (a, b) = spline.split_at(0.5)?; + for part in [Curve::Nurbs(a), Curve::Nurbs(b)] { + sampled.push(sample(&part, &mut remaining)?); + next.push(part); + } + changed = true; + continue; + } + } + remaining = remaining.checked_sub(points.len())?; + next.push(curve); + sampled.push(points); + } + if !changed { + return None; + } + spans = next; + points = sampled; + } + None +} + +fn sample(curve: &Curve, remaining: &mut usize) -> Option> { + let Curve::Nurbs(spline) = curve else { + let points = curve.tessellate_angle(DEFAULT_ANGLE); + *remaining = remaining.checked_sub(points.len())?; + return Some(points); + }; + let mut min = [f64::INFINITY; 2]; + let mut max = [f64::NEG_INFINITY; 2]; + for point in spline.control_points() { + for axis in 0..2 { + min[axis] = min[axis].min(point[axis]); + max[axis] = max[axis].max(point[axis]); + } + } + // Scale chord error to this span; splitting tightens only crossed spans. + let tolerance = (max[0] - min[0]).hypot(max[1] - min[1]) * (1.0 - (DEFAULT_ANGLE * 0.5).cos()); + if !tolerance.is_finite() || tolerance <= 0.0 { + return None; + } + let points = curve.tessellate_within(tolerance); + *remaining = remaining.checked_sub(points.len())?; + Some(points) +} + +fn crossings( + edges: &[impl AsRef<[[f64; 2]]>], + project: &impl Fn([f64; 2]) -> [f64; 2], + work_left: &mut usize, +) -> Option> { + let mut marked = vec![false; edges.len()]; + for projected in [false, true] { + let mut segments = Vec::new(); + for (edge, points) in edges.iter().enumerate() { + for pair in points.as_ref().windows(2) { + let [a, b] = [pair[0], pair[1]].map(|p| if projected { project(p) } else { p }); + if !a.into_iter().chain(b).all(f64::is_finite) { + return None; + } + segments.push((a[0].min(b[0]), a[0].max(b[0]), edge, a, b)); + } + } + segments.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + for (index, &(_, right, edge, a, b)) in segments.iter().enumerate() { + for &(left, _, other, c, d) in &segments[index + 1..] { + if left > right { + break; + } + *work_left = work_left.checked_sub(1)?; + if a[1].max(b[1]) < c[1].min(d[1]) || c[1].max(d[1]) < a[1].min(b[1]) { + continue; + } + let cross = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| { + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + }; + let opposite = |a: f64, b: f64| (a < 0.0 && b > 0.0) || (a > 0.0 && b < 0.0); + let inside = |p: [f64; 2], a: [f64; 2], b: [f64; 2]| { + cross(a, b, p) == 0.0 + && (0..2).any(|axis| { + p[axis] > a[axis].min(b[axis]) && p[axis] < a[axis].max(b[axis]) + }) + }; + if opposite(cross(a, b, c), cross(a, b, d)) + && opposite(cross(c, d, a), cross(c, d, b)) + || (inside(a, c, d) + || inside(b, c, d) + || inside(c, a, b) + || inside(d, a, b) + || a != b && (a == c && b == d || a == d && b == c)) + { + marked[edge] = true; + marked[other] = true; + } + } + } + } + Some(marked) +} diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs new file mode 100644 index 000000000..175ce7a92 --- /dev/null +++ b/src/scene/hatch_boundary/tests.rs @@ -0,0 +1,246 @@ +use super::*; +use crate::scene::Scene; +use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge, SplineEdge}; +use acadrust::types::{Vector2, Vector3}; +use cadkernel::geom2d::NurbsCurve; + +fn narrow(clearance: f64, offset: [f64; 2]) -> NurbsCurve { + let line = |a: [f64; 2], b: [f64; 2]| { + [ + a, + std::array::from_fn(|i| a[i] + (b[i] - a[i]) / 3.0), + std::array::from_fn(|i| a[i] + 2.0 * (b[i] - a[i]) / 3.0), + b, + ] + }; + let [a, b, c, d, e, f] = [ + [-1.0, -1.0], + [0.0, -1.0], + [0.0, 1.0], + [clearance, 0.999], + [-0.5, 1.5], + [-1.0, 1.5], + ]; + // The returning cubic passes close to the vertical side b--c. + let spans = [ + line(a, b), + line(b, c), + line(c, d), + [d, [clearance, 1.1], [-0.3, 1.5], e], + line(e, f), + line(f, a), + ]; + let mut controls = vec![a]; + for span in spans { + controls.extend_from_slice(&span[1..]); + } + for p in &mut controls { + for i in 0..2 { + p[i] += offset[i]; + } + } + let mut knots = vec![0.0; 4]; + for i in 1..6 { + knots.extend([i as f64; 3]); + } + knots.extend([6.0; 4]); + NurbsCurve::new(3, controls, knots, None).unwrap() +} + +fn edge(spline: NurbsCurve) -> BoundaryEdge { + BoundaryEdge::Spline(SplineEdge { + degree: spline.degree() as i32, + rational: false, + periodic: false, + knots: spline.knots().to_vec(), + control_points: spline + .control_points() + .iter() + .map(|p| Vector3::new(p[0], p[1], 0.0)) + .collect(), + fit_points: Vec::new(), + start_tangent: Vector2::new(0.0, 0.0), + end_tangent: Vector2::new(0.0, 0.0), + }) +} + +fn rectangle(a: [f64; 2], b: [f64; 2], offset: [f64; 2]) -> BoundaryPath { + let points = [a, [b[0], a[1]], b, [a[0], b[1]]] + .map(|p| Vector2::new(p[0] + offset[0], p[1] + offset[1])); + let mut path = BoundaryPath::new(); + path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new( + points.to_vec(), + true, + ))); + path +} + +#[test] +fn narrow_fill_preserves_corners_holes_and_shared_origin() { + let curve = narrow(0.00001, [0.0; 2]); + let area: f64 = (0..6) + .map(|i| { + Curve::Nurbs(curve.trimmed(i as f64 / 6.0, (i + 1) as f64 / 6.0).unwrap()) + .enclosed_area() + }) + .sum(); + for (offset, shuffled, multiple) in [ + ([0.0; 2], false, false), + ([1e6, -1e6], false, false), + ([0.0; 2], true, true), + ([1e6, -1e6], true, true), + ] { + let spline = narrow(0.00001, offset); + let mut path = BoundaryPath::new(); + path.edges = if shuffled { + let (a, b) = spline.split_at(0.5).unwrap(); + vec![edge(b), edge(a.reversed())] + } else { + vec![edge(spline.clone())] + }; + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + if multiple { + hatch.paths.extend([ + rectangle([-0.8, -0.8], [-0.2, -0.2], offset), + rectangle([-0.6, -0.6], [-0.4, -0.4], offset), + rectangle([1.0, -1.0], [2.0, 2.5], offset), + ]); + } + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + if multiple { + assert_eq!(model.world_origin, [offset[0] + 0.5, offset[1] + 0.75]); + } + let project = |p: [f64; 2]| { + [ + (p[0] - model.world_origin[0]) as f32, + (p[1] - model.world_origin[1]) as f32, + ] + }; + let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE); + let original: Vec<_> = original + .into_iter() + .map(|p| project(p).map(f64::from)) + .collect(); + assert!(triangulate_rings(&[original]).1.is_empty()); + for i in 0..=6 { + assert!(model + .boundary + .contains(&project(spline.point_at(i as f64 / 6.0)))); + } + assert_eq!(model.boundary_paths.as_deref().unwrap(), &hatch.paths); + let (vertices, indices) = model.fill_mesh(); + assert!(!indices.is_empty()); + let mut filled_area = 0.0; + for triangle in indices.chunks_exact(3) { + let [a, b, c] = [triangle[0], triangle[1], triangle[2]] + .map(|i| vertices[i as usize].map(f64::from)); + let twice_area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + assert!(twice_area > 0.0); + filled_area += twice_area * 0.5; + if multiple { + let p = + [0, 1].map(|i| (a[i] + b[i] + c[i]) / 3.0 + model.world_origin[i] - offset[i]); + let inside = |lo, hi| p.iter().all(|&v| v > lo && v < hi); + assert!(!inside(-0.8, -0.2) || inside(-0.6, -0.4)); + } + } + let expected = area + if multiple { 3.5 - 0.36 + 0.04 } else { 0.0 }; + assert!( + (filled_area - expected).abs() < 0.001, + "{filled_area} vs {expected}" + ); + } +} + +#[test] +fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() { + for clearance in [0.01, 0.000001, 0.000002, 0.000005] { + let spline = narrow(clearance, [0.0; 2]); + let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE); + let mut path = BoundaryPath::new(); + path.edges.push(edge(spline)); + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + let expected: Vec<_> = original + .iter() + .map(|p| { + [ + (p[0] - model.world_origin[0]) as f32, + (p[1] - model.world_origin[1]) as f32, + ] + }) + .collect(); + assert_eq!(model.boundary.as_ref(), &expected); + assert_eq!(model.fill_mesh().1.is_empty(), clearance < 0.01); + } + let curve = Curve::Nurbs(narrow(0.01, [0.0; 2])); + assert!(refine( + &curve.tessellate_angle(DEFAULT_ANGLE), + || panic!("valid path resampled"), + |p| p + ) + .is_none()); + assert!(refine( + &vec![[0.0; 2]; MAX_RECOVERY_POINTS + 1], + || panic!("size cap ignored"), + |p| p + ) + .is_none()); +} + +#[test] +fn contacts_and_intersection_work_are_bounded() { + for (a, b, expected) in [ + ([[0.0, 0.0], [1.0, 1.0]], [[1.0, 1.0], [2.0, 0.0]], false), + ([[0.0, 0.0], [2.0, 0.0]], [[1.0, 0.0], [1.0, 1.0]], true), + ([[0.0, 0.0], [2.0, 0.0]], [[1.0, 0.0], [3.0, 0.0]], true), + ] { + assert_eq!(crossings(&[a, b], &|p| p, &mut 10).unwrap(), [expected; 2]); + } + let edges = vec![[[0.0, 0.0], [1.0, 1.0]]; 100]; + let mut budget = 7; + assert!(crossings(&edges, &|p| p, &mut budget).is_none()); + assert_eq!(budget, 0); +} + +#[test] +fn recovery_respects_the_total_hatch_vertex_cap() { + let curve = Curve::Nurbs(narrow(0.00001, [0.0; 2])); + let original = curve.tessellate_angle(DEFAULT_ANGLE); + let Curve::Nurbs(spline) = curve.clone() else { + unreachable!() + }; + let mut path = BoundaryPath::new(); + path.edges.push(edge(spline)); + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + let count = 16_384 - original.len() - 2; + let points = (0..count) + .map(|i| { + let angle = std::f64::consts::TAU * i as f64 / count as f64; + Vector2::new(1.5 + 0.5 * angle.cos(), 0.75 + 1.75 * angle.sin()) + }) + .collect(); + let mut other = BoundaryPath::new(); + other.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(points, true))); + hatch.paths.push(other); + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + let project = |p: [f64; 2]| { + [ + (p[0] - model.world_origin[0]) as f32 as f64, + (p[1] - model.world_origin[1]) as f32 as f64, + ] + }; + assert!(refine(&original, || vec![curve], project).unwrap().len() > original.len()); + assert_eq!(model.boundary.len(), 16_384); + let expected: Vec<_> = original + .into_iter() + .map(|p| project(p).map(|v| v as f32)) + .collect(); + assert_eq!(&model.boundary[..expected.len()], expected.as_slice()); +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..d2b43361e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod dimension_assoc; pub(crate) mod centermark; mod dwg_native_constraints; mod entity; +mod hatch_boundary; mod group_layer; mod layout; mod limits;