diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 534568a14..e8aa40d3e 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -19,7 +19,7 @@ use super::{ }; #[cfg(test)] -pub(crate) use paint::{STROKES_OUTLINED, offset_between}; +pub(crate) use paint::{STROKES_OUTLINED, anchors_moved, offset_between}; mod images; mod nodes; diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 1ed98e8ed..5228cc881 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -384,71 +384,99 @@ thread_local! { pub(crate) static STROKES_OUTLINED: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// The offset that moves path `was` onto path `now`, when `now` is `was` moved (each coordinate -/// within a few units in the last place of the moved one, the error that adding the offset and -/// measuring it from the first points can make). `None` otherwise, and for an empty path. +/// The offset that moves path `was` onto path `now`, when `now` is `was` moved (each point within +/// the rounding that [`Offset::holds`] allows). `None` otherwise, and for an empty path. pub(crate) fn offset_between(was: &BezPath, now: &BezPath) -> Option { - use kurbo::PathEl; - let (was, now) = (was.elements(), now.elements()); - if was.len() != now.len() { + if !matches!(was.elements().first(), Some(kurbo::PathEl::MoveTo(_))) { return None; } - let (by, first) = match (was.first()?, now.first()?) { - (PathEl::MoveTo(a), PathEl::MoveTo(b)) => first_offset(*a, *b)?, - _ => return None, - }; - let near = |a: &kurbo::Point, b: &kurbo::Point| moved_by(*a, *b, by, first); - let moved = was.iter().zip(now).all(|pair| match pair { - (PathEl::MoveTo(a), PathEl::MoveTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => near(a, b), - (PathEl::QuadTo(a0, a1), PathEl::QuadTo(b0, b1)) => near(a0, b0) && near(a1, b1), - (PathEl::CurveTo(a0, a1, a2), PathEl::CurveTo(b0, b1, b2)) => near(a0, b0) && near(a1, b1) && near(a2, b2), - (PathEl::ClosePath, PathEl::ClosePath) => true, - _ => false, - }); - moved.then_some(by) + let mut offset = Offset::new(); + (path_pairs(was, now, |a, b| offset.takes(a, b)) && path_pairs(was, now, |a, b| offset.holds(a, b))).then(|| offset.by()) } -/// The offset from first point `a` to first point `b` and the largest size of their coordinates, -/// which [`moved_by`] measures its tolerance with. `None` when the offset is not finite. -fn first_offset(a: kurbo::Point, b: kurbo::Point) -> Option<(kurbo::Vec2, f64)> { - let by = b - a; - by.is_finite().then(|| (by, a.x.abs().max(b.x.abs()).max(a.y.abs()).max(b.y.abs()))) +/// Whether `was` and `now` have the same elements, and `f` holds for each pair of their points. +/// Stops at the first pair for which `f` does not hold. +fn path_pairs(was: &BezPath, now: &BezPath, mut f: impl FnMut(kurbo::Point, kurbo::Point) -> bool) -> bool { + use kurbo::PathEl; + let (was, now) = (was.elements(), now.elements()); + was.len() == now.len() + && was.iter().zip(now).all(|pair| match pair { + (PathEl::MoveTo(a), PathEl::MoveTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => f(*a, *b), + (PathEl::QuadTo(a0, a1), PathEl::QuadTo(b0, b1)) => f(*a0, *b0) && f(*a1, *b1), + (PathEl::CurveTo(a0, a1, a2), PathEl::CurveTo(b0, b1, b2)) => f(*a0, *b0) && f(*a1, *b1) && f(*a2, *b2), + (PathEl::ClosePath, PathEl::ClosePath) => true, + _ => false, + }) } -/// Whether `b` is `a` moved by `by`, each coordinate within a few units in the last place (see -/// [`offset_between`]; `first` from [`first_offset`]). -fn moved_by(a: kurbo::Point, b: kurbo::Point, by: kurbo::Vec2, first: f64) -> bool { - let close = |a: f64, by: f64, b: f64| (a + by - b).abs() <= 4.0 * f64::EPSILON * (a.abs() + b.abs() + first); - close(a.x, by.x, b.x) && close(a.y, by.y, b.y) +/// One offset between pairs of points. Pairs are read once with [`Offset::takes`], which measures +/// the offset on each axis at the pair with the smallest coordinates on that axis, and once more +/// with [`Offset::holds`], which checks every pair against that offset. +struct Offset { + by: [f64; 2], + size: [f64; 2], +} + +impl Offset { + fn new() -> Self { + Self { by: [0.0; 2], size: [f64::INFINITY; 2] } + } + + /// Whether `b` is `a` moved by the offset measured so far, on each axis within 3 units in the + /// last place of the larger of the pair's coordinates and those of the pair the offset was + /// measured at. A pair with smaller coordinates than any before it on an axis then measures the + /// offset on that axis. + fn takes(&mut self, a: kurbo::Point, b: kurbo::Point) -> bool { + [(a.x, b.x), (a.y, b.y)].into_iter().zip(self.by.iter_mut().zip(&mut self.size)).all(|((a, b), (by, size))| { + let at = a.abs().max(b.abs()); + if at.is_nan() || at >= *size { + return (b - a - *by).abs() <= 3.0 * ulp(at); + } + let near = size.is_infinite() || (b - a - *by).abs() <= 3.0 * ulp(*size); + (*by, *size) = (b - a, at); + near && by.is_finite() + }) + } + + /// Whether `b` is `a` moved by the offset, on each axis within 3 units in the last place of the + /// larger of the pair's coordinates: the most that rounding `b`, the pair the offset was measured + /// at (whose coordinates are no larger), and the two offsets can make together. + fn holds(&self, a: kurbo::Point, b: kurbo::Point) -> bool { + let near = |a: f64, b: f64, by: f64| (b - a - by).abs() <= 3.0 * ulp(a.abs().max(b.abs())); + near(a.x, b.x, self.by[0]) && near(a.y, b.y, self.by[1]) + } + + fn by(&self) -> kurbo::Vec2 { + kurbo::Vec2::new(self.by[0], self.by[1]) + } +} + +/// The distance from `x`, which is 0 or more, to the next larger `f64`. +fn ulp(x: f64) -> f64 { + f64::from_bits(x.to_bits() + 1) - x } /// Whether the anchors and handles of path `now` are those of path `was` moved by one offset, /// measured as [`offset_between`] measures it, with the same subpaths, anchor counts and closed /// subpaths. It reads the anchors without making either path ([`Node::stroke_path`]), and stops at /// the first anchor that differs. `false` for objects other than paths. -fn anchors_moved(was: &Node, now: &Node) -> bool { +pub(crate) fn anchors_moved(was: &Node, now: &Node) -> bool { let (vectorcraft_doc::NodeKind::Path { path: was, .. }, vectorcraft_doc::NodeKind::Path { path: now, .. }) = (&was.kind, &now.kind) else { return false; }; - if was.subpaths.len() != now.subpaths.len() { - return false; - } - let mut offset = None; - for (a, b) in was.subpaths.iter().zip(&now.subpaths) { - if a.closed != b.closed || a.anchors.len() != b.anchors.len() { - return false; - } - for (a, b) in a.anchors.iter().zip(&b.anchors) { - if offset.is_none() { - offset = first_offset(a.p, b.p); - } - let Some((by, first)) = offset else { return false }; - if !(moved_by(a.p, b.p, by, first) && moved_by(a.h_in, b.h_in, by, first) && moved_by(a.h_out, b.h_out, by, first)) { - return false; - } - } - } - true + let mut offset = Offset::new(); + anchor_pairs(was, now, |a, b| offset.takes(a, b)) && anchor_pairs(was, now, |a, b| offset.holds(a, b)) +} + +/// Whether `was` and `now` have the same subpaths, anchor counts and closed subpaths, and `f` holds +/// for each pair of their anchors and handles. Stops at the first pair for which `f` does not hold. +fn anchor_pairs(was: &vectorcraft_geom::PathData, now: &vectorcraft_geom::PathData, mut f: impl FnMut(kurbo::Point, kurbo::Point) -> bool) -> bool { + was.subpaths.len() == now.subpaths.len() + && was.subpaths.iter().zip(&now.subpaths).all(|(a, b)| { + a.closed == b.closed + && a.anchors.len() == b.anchors.len() + && a.anchors.iter().zip(&b.anchors).all(|(a, b)| f(a.p, b.p) && f(a.h_in, b.h_in) && f(a.h_out, b.h_out)) + }) } fn crops() -> bool { diff --git a/crates/render/src/tests/movedstroke.rs b/crates/render/src/tests/movedstroke.rs index 99d40cf48..de35aa592 100644 --- a/crates/render/src/tests/movedstroke.rs +++ b/crates/render/src/tests/movedstroke.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{PathData, Point, Vec2}; -use crate::renderer::{STROKES_OUTLINED, offset_between}; +use crate::renderer::{STROKES_OUTLINED, anchors_moved, offset_between}; use crate::*; const SIZE: u32 = 200; @@ -246,16 +246,84 @@ fn another_width_profile_outlines_the_stroke_again() { #[test] fn a_small_edit_far_from_the_origin_is_not_taken_for_a_move() { - let far = Affine::translate((1e9, -1e9)); - let was = far * zigzag(20); + for (far, edits) in [(1e9, &[0.5, 0.0005][..]), (1e15, &[0.5][..])] { + let was = Affine::translate((far, -far)) * zigzag(20); + let by = Vec2::new(0.25, 3.0); + let moved = Affine::translate(by) * was.clone(); + assert_eq!( + offset_between(&was, &moved), + Some(moved.elements()[0].end_point().unwrap() - was.elements()[0].end_point().unwrap()), + "at {far}: moved" + ); + let node = |bp: &BezPath| Node::path(NodeId(1), PathData::from_bezpath(bp), Appearance::default()); + let mut moved_node = node(&was); + moved_node.transform(Affine::translate(by), true); + assert!(anchors_moved(&node(&was), &moved_node), "at {far}: anchors moved"); + for &edit in edits { + let mut els = moved.elements().to_vec(); + if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) { + p.y += edit; + } + let edited = BezPath::from_vec(els); + assert_eq!(offset_between(&was, &edited), None, "at {far}: an anchor moved by {edit} pt"); + assert!(!anchors_moved(&node(&was), &node(&edited)), "at {far}: an anchor moved by {edit} pt, read from the anchors"); + // The third segment is a curve: its first handle. + let mut els = moved.elements().to_vec(); + if let Some(kurbo::PathEl::CurveTo(h, _, _)) = els.get_mut(3) { + h.x += edit; + } + assert!(!anchors_moved(&node(&was), &node(&BezPath::from_vec(els))), "at {far}: a handle moved by {edit} pt"); + } + } +} + +#[test] +fn an_edit_near_the_origin_is_not_taken_for_a_move_when_the_path_starts_far_from_it() { + // A subpath at 1e16 pt, where coordinates step by 2 pt, then one near the origin. + let mut was = BezPath::new(); + was.move_to((1e16, 1e16)); + was.line_to((1e16 + 64.0, 1e16)); + was.extend(zigzag(20)); let by = Vec2::new(0.25, 3.0); let moved = Affine::translate(by) * was.clone(); - assert_eq!(offset_between(&was, &moved), Some(moved.elements()[0].end_point().unwrap() - was.elements()[0].end_point().unwrap())); - let mut els = moved.elements().to_vec(); - if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) { - p.y += 0.0005; + assert_eq!(offset_between(&was, &moved), Some(by), "moved: the offset of the subpath near the origin"); + let node = |bp: &BezPath| Node::path(NodeId(1), PathData::from_bezpath(bp), Appearance::default()); + let mut moved_node = node(&was); + moved_node.transform(Affine::translate(by), true); + assert!(anchors_moved(&node(&was), &moved_node), "anchors moved"); + // The first and the third anchor of the subpath near the origin. + for at in [2, 4] { + for edit in [1.0, 0.5] { + let mut els = moved.elements().to_vec(); + if let Some(kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p)) = els.get_mut(at) { + p.y += edit; + } + let edited = BezPath::from_vec(els); + assert_eq!(offset_between(&was, &edited), None, "element {at} moved by {edit} pt"); + assert!(!anchors_moved(&node(&was), &node(&edited)), "element {at} moved by {edit} pt, read from the anchors"); + } } - assert_eq!(offset_between(&was, &BezPath::from_vec(els)), None, "an anchor moved by 0.0005 pt"); +} + +#[test] +fn points_each_near_the_one_before_are_checked_against_one_offset() { + let path = |xs: [f64; 3]| { + let mut bp = BezPath::new(); + bp.move_to((xs[0], 0.0)); + bp.line_to((xs[1], 0.0)); + bp.line_to((xs[2], 0.0)); + bp + }; + let node = |bp: &BezPath| Node::path(NodeId(1), PathData::from_bezpath(bp), Appearance::default()); + let was = path([1e16, 1e15, 1e14]); + // Coordinates step by 2, 0.125 and 0.015625 pt there. + let moved = Affine::translate((6.25, 0.0)) * was.clone(); + assert_eq!(offset_between(&was, &moved), Some(Vec2::new(6.25, 0.0)), "moved"); + assert!(anchors_moved(&node(&was), &node(&moved)), "anchors moved"); + // Each point is within rounding of the one before it, but the first did not move. + let edited = path([1e16, 1e15 + 6.0, 1e14 + 6.25]); + assert_eq!(offset_between(&was, &edited), None); + assert!(!anchors_moved(&node(&was), &node(&edited))); } #[test]