diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 2b6d4a96..80346b94 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -639,7 +639,9 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { _ => false, }; let grid = Grid::new(&boxes); - let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; + // Each cut's parameter, and whether it is weak: a crossing next to an end that misses one of + // the two curves (below), which gives way to any other cut it is merged with. + let mut cuts: Vec> = vec![Vec::new(); shapes.len()]; // End points of other curves lying on each shape. let mut end_cells: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); for (k, p) in ends.iter().enumerate() { @@ -657,7 +659,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { && let Some(t) = sh.param_on(*p, tol) && let Some(cu) = cuts.get_mut(i) { - cu.push(t); + cu.push((t, false)); } } } @@ -752,30 +754,35 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { for p in pts { let on_x = x.param_on(p, tol).or_else(|| end_param(x, p, tol)); let on_y = y.param_on(p, tol).or_else(|| end_param(y, p, tol)); - // Crossings at (or next to) curve ends are already cuts. - let near_end = || { + // Crossings at (or next to) curve ends are already cuts. Not next to an end of + // one of the two curves that misses the other: nothing cut the other curve + // there, and it would cross without meeting it (a line 3e-5 from the + // junction of two curves of a loop, #755). Such a crossing is a weak cut. + let on = |sh: &Shape, e: Vec2| sh.param_on(e, tol).is_some() || end_param(sh, e, tol).is_some(); + let misses = |e: Vec2| (end_param(x, e, tol).is_some() && !on(y, e)) || (end_param(y, e, tol).is_some() && !on(x, e)); + let near_ends = || { let (cx, cy) = grid.cell(p); - (-1..=1).any(|dx| { - (-1..=1).any(|dy| { - end_cells - .get(&(cx + dx, cy + dy)) - .is_some_and(|v| v.iter().any(|k| ends.get(*k).is_some_and(|e| e.dist(p) < tol * 100.0))) - }) - }) + (-1..=1) + .flat_map(|dx| (-1..=1).map(move |dy| (cx + dx, cy + dy))) + .flat_map(|c| end_cells.get(&c).map(Vec::as_slice).unwrap_or(&[])) + .filter_map(|k| ends.get(*k).copied()) + .filter(|e| e.dist(p) < tol * 100.0) + .fold((false, false), |(cut, weak), e| if misses(e) { (cut, true) } else { (true, weak) }) }; - if on_x.is_none() || on_y.is_none() || near_end() { + let (already, weak) = near_ends(); + if on_x.is_none() || on_y.is_none() || already { continue; } if let (Some(tx), Some(ty)) = (on_x, on_y) { if x.param_on(p, tol).is_some() && let Some(c) = cuts.get_mut(i) { - c.push(tx); + c.push((tx, weak)); } if y.param_on(p, tol).is_some() && let Some(c) = cuts.get_mut(j) { - c.push(ty); + c.push((ty, weak)); } if (gi, gj) != (i, j) { anchors.push(p); @@ -815,21 +822,33 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let mut edges: Vec = Vec::new(); for (k, (ci, sh)) in shapes.iter().enumerate() { let mut ts = cuts.get(k).cloned().unwrap_or_default(); - ts.sort_by(f64::total_cmp); + ts.sort_by(|a, b| a.0.total_cmp(&b.0)); // Cuts closer than a few tolerances are one cut (tangent touches give near-double roots). + // A weak cut gives way to the other: it only adds a cut where there was none. let scale = match *sh { Shape::Line { a, b } => a.dist(b), Shape::Round { r, .. } => r, }; let close = (tol * 100.0 / scale.max(1e-12)).max(1e-9); - ts.dedup_by(|a, b| (*a - *b).abs() < close); + ts.dedup_by(|a, b| { + let same = (a.0 - b.0).abs() < close; + if same && b.1 && !a.1 { + *b = *a; + } + same + }); if let (Shape::Round { sweep, .. }, Some(&first), Some(&last)) = (*sh, ts.first(), ts.last()) && sweep >= TAU - 1e-12 && ts.len() > 1 - && (first + TAU - last).abs() < close + && (first.0 + TAU - last.0).abs() < close { - ts.pop(); + if first.1 && !last.1 { + ts.remove(0); + } else { + ts.pop(); + } } + let ts: Vec = ts.into_iter().map(|(t, _)| t).collect(); let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default(); let analytic = analytic(*ci); let coverage = tags.get(k).copied().flatten().or_else(|| { diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index f6651e71..c489d0c4 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -967,3 +967,91 @@ fn a_degree_nine_control_spline_stays_exact_and_closes_a_profile_exactly() { high.push(sk.add_point(v(21.0, -2.0), None).unwrap()); assert!(sk.add_curve(CurveKind::Spline { pts: high, control: true, degree: 10, periodic: false }, None).is_err()); } + +/// #755: a line drawn through a junction of a loop, but 2e-5 off it, still splits the loop. The +/// junction lies on the loop's two curves only, so it cuts neither of them where the line +/// crosses, and that crossing, next to the junction, was taken for one already cut there: the +/// line crossed the loop without meeting it, and the sketch had no profiles at all. +#[test] +fn profiles_line_passing_next_to_a_junction_splits_the_loop() { + let o = v(0.0, 0.0); + let at = |r: f64, a: f64| v(r * a.cos(), r * a.sin()); + // A slot: half circles of radius 1 round (0, 0) and (-5, 0), joined by the lines y = 1 and + // y = -1, which meet the arcs tangentially at (0, 1) and (0, -1). + let mut sk = Sketch::new(); + let right = sk.add_arc(o, v(0.0, -1.0), v(0.0, 1.0), [None; 3], None).unwrap(); + let left = sk.add_arc(v(-5.0, 0.0), v(-5.0, 1.0), v(-5.0, -1.0), [None; 3], None).unwrap(); + let ends = |i: usize| match sk.curves[i].kind { + CurveKind::Arc { a, b, .. } => (a, b), + _ => panic!("an arc"), + }; + let ((r0, r1), (l0, l1)) = (ends(right), ends(left)); + sk.add_line_pts(r1, l0, None).unwrap(); + sk.add_line_pts(l1, r0, None).unwrap(); + // Lines from (0, 0) through the junctions to an arc of radius 2. The upper one is turned by + // 2e-5 rad, so it passes 2e-5 from (0, 1) and crosses the line y = 1 next to it. + let d = 2e-5; + let (top, bottom) = (at(2.0, FRAC_PI_2 + d), v(0.0, -2.0)); + sk.add_line(o, top, None, None, None).unwrap(); + sk.add_line(o, bottom, None, None, None).unwrap(); + sk.add_arc(o, bottom, top, [None; 3], None).unwrap(); + let mut areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + areas.sort_by(f64::total_cmp); + // The right half disc, the band round it out to radius 2, and the rest of the slot. + let want = [FRAC_PI_2, FRAC_PI_2 * (4.0 - 1.0), 10.0 + FRAC_PI_2]; + assert_eq!(areas.len(), 3, "{areas:?}"); + for (got, want) in areas.iter().zip(want) { + assert!((got - want).abs() < 1e-3, "{areas:?} against {want}"); + } +} + +/// A line ending 2e-5 from a corner, next to a crossing there: the corner, where the side of the +/// square meets the long line, stays a cut of the long line, and the square stays a profile. +#[test] +fn profiles_crossing_next_to_a_corner_keeps_the_corner() { + let mut sk = Sketch::new(); + let (a, b) = (sk.add_point(v(1.0, 0.0), None).unwrap(), sk.add_point(v(2.0, 0.0), None).unwrap()); + let (c, d) = (sk.add_point(v(2.0, 1.0), None).unwrap(), sk.add_point(v(1.0, 1.0), None).unwrap()); + // The square's base is part of a longer line, which its sides end on. + sk.add_line(v(0.0, 0.0), v(3.0, 0.0), None, None, None).unwrap(); + for (p, q) in [(b, c), (c, d), (d, a)] { + sk.add_line_pts(p, q, None).unwrap(); + } + // A line hanging across the long one, 2e-5 from the square's corner (1, 0). + sk.add_line(v(1.0 - 2e-5, -0.5), v(1.0 - 2e-5, 0.5), None, None, None).unwrap(); + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 1, "{areas:?}"); + assert!(areas.iter().all(|x| (x - 1.0).abs() < 1e-9), "{areas:?}"); +} + +/// A line across a rectangle, and a stray line ending on it 1e-3 from where it crosses the +/// rectangle's side: both the crossing and that end cut the long line, and the rectangle is +/// split into its two halves. +#[test] +fn profiles_crossing_next_to_a_stray_end_still_splits() { + let mut sk = Sketch::new(); + rect(&mut sk, [v(0.0, -1.0), v(1.0, -1.0), v(1.0, 1.0), v(0.0, 1.0)]); + sk.add_line(v(-1.0, 0.0), v(2.0, 0.0), None, None, None).unwrap(); + sk.add_line(v(0.00099998, 0.0000099), v(0.3, 0.6), None, None, None).unwrap(); + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 2, "{areas:?}"); + assert!(areas.iter().all(|x| (x - 1.0).abs() < 1e-9), "{areas:?}"); +} + +/// A line whose own end overshoots a crossing line by 2e-5, with a rectangle standing on it 1e-3 +/// further on: the crossing next to that end gives way to the rectangle's corner, which still +/// cuts the line, and the rectangle stays a profile. +#[test] +fn profiles_crossing_next_to_an_overshooting_end_keeps_the_corner_beside_it() { + let mut sk = Sketch::new(); + sk.add_line(v(-2e-5, 0.0), v(3.0, 0.0), None, None, None).unwrap(); + sk.add_line(v(0.0, -0.5), v(0.0, 0.5), None, None, None).unwrap(); + let x = 0.00099999; + let p = [v(x, 0.0000099), v(x, 1.0), v(2.0, 1.0), v(2.0, 0.0)].map(|q| sk.add_point(q, None).unwrap()); + for w in p.windows(2) { + sk.add_line_pts(w[0], w[1], None).unwrap(); + } + let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); + assert_eq!(areas.len(), 1, "{areas:?}"); + assert!(areas.iter().all(|a| (a - (2.0 - x)).abs() < 1e-4), "{areas:?}"); +}