diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 4ccbeec7e..0968fb588 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -291,33 +291,61 @@ fn convert_points(s: &mut Session, p: &Value) -> Result { }) } +/// Most samples of a drag that Erase / Smooth look at. +const MAX_DRAG_SAMPLES: usize = 4096; + /// Erase / Smooth: the stretch of one subpath touched by the drag. fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { let cmd = if smooth { "path.smooth" } else { "path.erase" }; let id = super::id_param(p, "id").ok_or_else(|| bad(cmd, "missing id"))?; let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); - let pts: Vec = p + let mut pts: Vec = p .get("points") .and_then(Value::as_array) .map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()) .unwrap_or_default(); + // Each sample is a search of every segment: repeated samples add nothing, and a drag has no use + // for more than MAX_DRAG_SAMPLES (evenly thinned, the first and last kept). Whether the drag + // touched the path is decided on every distinct sample, though: see below. + let mut seen = std::collections::HashSet::new(); + pts.retain(|q| seen.insert((q.x.to_bits(), q.y.to_bits()))); + let thinned: Vec = if pts.len() > MAX_DRAG_SAMPLES { + let (n, last) = (pts.len(), pts.last().copied()); + (0..MAX_DRAG_SAMPLES).filter_map(|i| pts.get((i as u64 * n as u64 / MAX_DRAG_SAMPLES as u64) as usize).copied()).chain(last).collect() + } else { + Vec::new() + }; s.edit(|d, sel| { path_editable(d, id, cmd)?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let scale = inv.determinant().abs().sqrt().max(1e-9); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; // Where the drag touched the path, per subpath. - let mut hits: Vec<(usize, usize, f64)> = Vec::new(); - for q in &pts { - if let Some((si, seg, t, _, dist)) = it.path.nearest(inv * *q) - && dist <= tol * scale - { - hits.push((si, seg, t)); + let hits_of = |set: &[Point]| -> Vec<(usize, usize, f64)> { + let mut hits = Vec::new(); + for q in set { + if let Some((si, seg, t, _, dist)) = it.path.nearest(inv * *q) + && dist <= tol * scale + { + hits.push((si, seg, t)); + } + } + hits + }; + // A long drag is searched thinned; only when that finds nothing are all its samples, so a + // touch the thinning skipped over still counts and a long drag is never refused for it. + let mut hits = hits_of(if thinned.is_empty() { &pts } else { &thinned }); + if hits.is_empty() && !thinned.is_empty() { + hits = hits_of(&pts); + } + // The subpath with the most hits (the last of equals), counted in one pass. + let mut counts = vec![0usize; it.path.subpaths.len()]; + for h in &hits { + if let Some(n) = counts.get_mut(h.0) { + *n += 1; } } - let Some(si) = - (0..it.path.subpaths.len()).max_by_key(|i| hits.iter().filter(|h| h.0 == *i).count()).filter(|i| hits.iter().any(|h| h.0 == *i)) - else { + let Some((si, _)) = counts.iter().copied().enumerate().filter(|(_, n)| *n > 0).max_by_key(|(_, n)| *n) else { return Err(bad(cmd, "the drag didn't touch the path")); }; let on: Vec<(usize, f64)> = hits.iter().filter(|h| h.0 == si).map(|h| (h.1, h.2)).collect(); @@ -663,6 +691,31 @@ mod tests { assert!(s.execute("path.erase", &json!({"id": id, "points": [[100, 600]]})).is_err()); } + /// A touch only thinning would skip still counts: a long drag is not refused as missing the path. + #[test] + fn a_touch_the_thinning_skips_still_counts() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pts: Vec = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, 300.0]})).collect(); + let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap(); + // 6,000 distinct samples far from the path but one, at an index thinning to 4,096 drops. + let drag: Vec = (0..6_000).map(|i| if i == 3 { json!([200.0, 300.0]) } else { json!([200.0 + f64::from(i) * 0.01, 900.0]) }).collect(); + assert!(s.execute("path.erase", &json!({"id": id, "points": drag})).is_ok()); + } + + /// A drag of many repeated and many distinct samples is thinned and still erases where it went. + #[test] + fn a_drag_with_many_samples_still_erases() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pts: Vec = (0..=20).map(|i| json!({"p": [100.0 + i as f64 * 20.0, if i % 2 == 0 { 300.0 } else { 306.0 }]})).collect(); + let id = s.execute("path.create", &json!({"anchors": pts})).unwrap()["id"].as_u64().unwrap(); + // 60,000 samples along the middle of the path, each point repeated. + let drag: Vec = (0..60_000).map(|i| json!([200.0 + f64::from(i % 6000) * 0.025, 303.0])).collect(); + let r = s.execute("path.erase", &json!({"id": id, "points": drag})).unwrap(); + assert_eq!(r["subpaths"], 2, "a gap in the middle"); + } + /// The Direct Selection's selected points stay the same points when the path changes under /// them: a point added before them, one of them deleted, the path reversed. #[test]