From c92bb5cdadc92ca224a946cedbbcb7fdc94cca8b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:54:04 +0200 Subject: [PATCH] Close hatch boundaries where a line ends just short of a flattened ellipse (#1690) (#1738) * Close hatch boundaries where a line ends just short of a flattened ellipse (#1690) * Bound the hatch endpoint snap scan by segment count and visited segments (#1690) * test: LINE returns its new objects as `handles` Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/hatch.rs | 9 +- crates/engine/src/cmd/hatch/assoc.rs | 5 +- crates/engine/tests/it/hatch_options.rs | 20 +++++ crates/geom/src/region.rs | 112 ++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index 5e2a0fa7..8b33cc71 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -34,12 +34,17 @@ pub fn specs() -> Vec { ] } +/// The tolerance curves are flattened with for boundary tracing, in a space with extents `ext`. +pub(super) fn outline_tol(ext: Bounds2) -> f64 { + (ext.width() + ext.height()).max(1e-9) / 20000.0 +} + /// Tessellated outlines of every visible curve in the space (hatch boundary candidates), each /// with the handle of the object it came from. fn outlines(d: &Drawing, space: &Space, near: Option) -> (Vec>, Vec) { let Some(store) = d.space(space) else { return (Vec::new(), Vec::new()) }; let ext = d.extents(space); - let tol = (ext.width() + ext.height()).max(1e-9) / 20000.0; + let tol = outline_tol(ext); let mut out = Vec::new(); let mut owners = Vec::new(); for e in store.iter() { @@ -206,6 +211,8 @@ pub(crate) fn loops_at(s: &Session, p: Vec2) -> Result> { let ext = d.extents(&space); let tol = ((ext.width() + ext.height()) * 1e-9).max(1e-9); let (polys, owners) = outlines(d, &space, None); + // A line that ends on a flattened curve (an ellipse) can stop short of its chords. + let polys = cadcraft_geom::region::snap_ends(&polys, outline_tol(ext), tol); let found = cadcraft_geom::region::boundary_at_gap(&polys, p, tol, gap_tolerance(d)) .ok_or_else(|| EngineError::Other("Valid hatch boundary not found.".into()))?; let outer = found.outer; diff --git a/crates/engine/src/cmd/hatch/assoc.rs b/crates/engine/src/cmd/hatch/assoc.rs index 17393e98..b653aa45 100644 --- a/crates/engine/src/cmd/hatch/assoc.rs +++ b/crates/engine/src/cmd/hatch/assoc.rs @@ -86,9 +86,10 @@ fn refill(before: &Drawing, after: &Drawing, l: &HatchLoop) -> Refill { if nb.is_empty() { return Refill::Open; } - let tol = (nb.width() + nb.height()).max(1e-9) / 20000.0; - let polys: Vec> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect(); + let tol = super::outline_tol(nb); let snap = ((nb.width() + nb.height()) * 1e-9).max(1e-9); + let polys: Vec> = live.iter().filter_map(|h| after.entity(*h)).flat_map(|e| object_polys(after, e, tol, nb)).collect(); + let polys = region::snap_ends(&polys, tol, snap); let gap = gap_tolerance(after); let found = if l.outer { // The face around a point inside the old loop, carried along with the objects. diff --git a/crates/engine/tests/it/hatch_options.rs b/crates/engine/tests/it/hatch_options.rs index a26d1bd5..7916681d 100644 --- a/crates/engine/tests/it/hatch_options.rs +++ b/crates/engine/tests/it/hatch_options.rs @@ -42,6 +42,26 @@ fn gap_tolerance_closes_small_gaps() { assert!(hatch(&s, &h).associative); } +#[test] +fn lines_ending_on_an_ellipse_close_a_boundary() { + // #1690: the verticals end on the true ellipse, which the boundary tracer sees as chords + // that pass just below those ends. + let mut s = Session::new(); + s.execute("ellipse", &json!({ "center": [0, 0], "major": [10, 0], "ratio": 0.5 })).unwrap(); + for x in [1.0f64, 2.0] { + let y = 5.0 * (1.0 - x * x / 100.0).sqrt(); + s.execute("line", &json!({ "points": [[x, 8.0], [x, y]] })).unwrap(); + } + let top = s.execute("line", &json!({ "points": [[0, 8], [3, 8]] })).unwrap()["handles"][0].clone(); + let h = handle(&s.execute("hatch", &json!({ "points": [[1.5, 7]] })).unwrap()); + let hh = hatch(&s, &h); + assert_eq!(hh.loops.len(), 1); + assert_eq!(hh.loops[0].sources.len(), 4); + // The associative refill traces the same way: sliding the top edge keeps the hatch. + s.execute("move", &json!({ "handles": [top], "delta": [0.5, 0] })).unwrap(); + assert!(hatch(&s, &h).associative); +} + #[test] fn pattern_origin_options() { let mut s = Session::new(); diff --git a/crates/geom/src/region.rs b/crates/geom/src/region.rs index 696a97de..98050cfd 100644 --- a/crates/geom/src/region.rs +++ b/crates/geom/src/region.rs @@ -434,6 +434,88 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec { out } +/// The curves with each open end that stops within `snap` of another curve moved onto that +/// curve (which gains a vertex there), so the two meet exactly. A curve that is flattened +/// into chords (an ellipse, a spline) lies up to the flattening tolerance off the true curve, +/// so a line that ends on the true curve can stop just short of the chords. +pub fn snap_ends(polys: &[Vec], snap: f64, tol: f64) -> Vec> { + let mut out: Vec> = polys.to_vec(); + if !(snap.is_finite() && snap > tol) { + return out; + } + // build() refuses this input anyway; don't spend ends x segments work on it. + let total: usize = polys.iter().map(|p| p.len().saturating_sub(1)).sum(); + if total > MAX_SEGMENTS { + return out; + } + // (target polyline, segment index, parameter on it, the point) for every vertex to insert. + let mut inserts: Vec<(usize, usize, f64, Vec2)> = Vec::new(); + let mut budget = MAX_GAP_PAIRS; + 'ends: for (i, pl) in polys.iter().enumerate() { + let (Some(&first), Some(&last)) = (pl.first(), pl.last()) else { continue }; + if pl.len() < 2 || first.near(last, tol) { + continue; + } + for at_end in [false, true] { + let e = if at_end { last } else { first }; + if !e.is_finite() { + continue; + } + let mut best: Option<(f64, usize, usize, f64, Vec2)> = None; + let mut touching = false; + for (j, other) in polys.iter().enumerate() { + if j == i { + continue; + } + for (k, w) in other.windows(2).enumerate() { + if budget == 0 { + break 'ends; + } + budget -= 1; + let [a, b] = w else { continue }; + if !(a.is_finite() && b.is_finite()) + || e.x < a.x.min(b.x) - snap + || e.x > a.x.max(b.x) + snap + || e.y < a.y.min(b.y) - snap + || e.y > a.y.max(b.y) + snap + { + continue; + } + let line = Line::new(*a, *b); + let t = line.param_of(e).clamp(0.0, 1.0); + let q = line.at(t); + let d = q.dist(e); + if d <= tol { + touching = true; + } else if d <= snap && best.is_none_or(|(bd, ..)| d < bd) { + best = Some((d, j, k, t, q)); + } + } + } + let Some((_, j, k, t, q)) = best.filter(|_| !touching) else { continue }; + let Some(slot) = out.get_mut(i).and_then(|v| if at_end { v.last_mut() } else { v.first_mut() }) else { continue }; + *slot = q; + let ends = polys.get(j).and_then(|o| o.get(k).zip(o.get(k + 1))); + if let Some((&a, &b)) = ends + && !q.near(a, tol) + && !q.near(b, tol) + { + inserts.push((j, k, t, q)); + } + } + } + // Later segments and larger parameters first, so earlier indices stay valid. + inserts.sort_by(|x, y| y.0.cmp(&x.0).then(y.1.cmp(&x.1)).then(y.2.total_cmp(&x.2))); + for (j, k, _, q) in inserts { + if let Some(v) = out.get_mut(j) + && k < v.len() + { + v.insert(k + 1, q); + } + } + out +} + /// The closed loop enclosing `p`, counter-clockwise, or `None`. pub fn enclosing_loop(polys: &[Vec], p: Vec2, tol: f64) -> Option> { enclosing_loop_gap(polys, p, tol, 0.0) @@ -721,4 +803,34 @@ mod tests { assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::NAN).is_none()); assert!(enclosing_loop_gap(&polys, p, 1e-9, f64::INFINITY).is_none()); } + + #[test] + fn line_ending_just_short_of_a_chord_is_joined_to_it() { + // A strip between two verticals and a flattened arc that bows below the ends of the + // verticals by 0.05 (the verticals stop on the true curve, the chord is lower). + let top = vec![Vec2::new(0.0, 10.0), Vec2::new(2.0, 10.0)]; + let left = vec![Vec2::new(0.0, 10.0), Vec2::new(0.0, 0.05)]; + let right = vec![Vec2::new(2.0, 10.0), Vec2::new(2.0, 0.05)]; + let chord = vec![Vec2::new(-5.0, 0.0), Vec2::new(5.0, 0.0)]; + let polys = vec![top, left, right, chord]; + let p = Vec2::new(1.0, 5.0); + assert!(enclosing_loop(&polys, p, 1e-9).is_none()); + let snapped = snap_ends(&polys, 0.1, 1e-9); + let l = enclosing_loop(&snapped, p, 1e-9).unwrap(); + assert!((shoelace(&l) - 20.0).abs() < 1e-6, "{}", shoelace(&l)); + // Too far away: left alone. + assert_eq!(snap_ends(&polys, 0.01, 1e-9), polys); + } + + #[test] + fn snap_ends_skips_input_over_the_segment_cap() { + let mut polys: Vec> = (0..=MAX_SEGMENTS) + .map(|i| { + let x = i as f64 * 10.0; + vec![Vec2::new(x, 0.0), Vec2::new(x, 1.0)] + }) + .collect(); + polys.push(vec![Vec2::new(0.0, 1.05), Vec2::new(5.0, 1.05)]); + assert_eq!(snap_ends(&polys, 0.1, 1e-9), polys); + } }