diff --git a/shape-brokkr/src/error.rs b/shape-brokkr/src/error.rs index 209ba3944..ebddf07aa 100644 --- a/shape-brokkr/src/error.rs +++ b/shape-brokkr/src/error.rs @@ -5,4 +5,6 @@ use thiserror::Error; pub enum Error { #[error("{1} {0:?}")] InvalidPath(BezPath, String), + #[error("No curve contains significant movement")] + NoSignificantActivity, } diff --git a/shape-brokkr/src/reuse.rs b/shape-brokkr/src/reuse.rs index b45dde7ee..b61236b57 100644 --- a/shape-brokkr/src/reuse.rs +++ b/shape-brokkr/src/reuse.rs @@ -4,105 +4,686 @@ //! path elements it starts with a move. use kurbo::{Affine, BezPath, PathEl, Point, Vec2}; +use log::warn; -use crate::Error; +const _ALMOST_EQUAL_TOLERANCE: f64 = 1e-9; +const _SIGNIFICANCE_FACTOR: f64 = 5.0; // Must be at least N x tolerance to be significant +const X_BASIS: Vec2 = Vec2::new(1.0, 0.0); -// Shift the path such that it begins at 0,0 -fn move_to_origin(path: &BezPath) -> Result { - if path.elements().is_empty() { - return Ok(path.clone()); +/// +trait AlmostEqual { + fn almost_equals(self, other: Self, tolerance: f64) -> bool; +} + +impl AlmostEqual for f64 { + #[inline] + fn almost_equals(self, other: Self, tolerance: f64) -> bool { + (self - other).abs() <= tolerance + } +} + +impl AlmostEqual for Vec2 { + #[inline] + fn almost_equals(self, other: Self, tolerance: f64) -> bool { + self.x.almost_equals(other.x, tolerance) && self.y.almost_equals(other.y, tolerance) + } +} + +impl AlmostEqual for Point { + #[inline] + fn almost_equals(self, other: Self, tolerance: f64) -> bool { + self.x.almost_equals(other.x, tolerance) && self.y.almost_equals(other.y, tolerance) + } +} + +impl AlmostEqual for &PathEl { + fn almost_equals(self, other: Self, tolerance: f64) -> bool { + match (self, other) { + (PathEl::MoveTo(sp0), PathEl::MoveTo(op0)) => sp0.almost_equals(*op0, tolerance), + (PathEl::LineTo(sp0), PathEl::LineTo(op0)) => sp0.almost_equals(*op0, tolerance), + (PathEl::QuadTo(sp0, sp1), PathEl::QuadTo(op0, op1)) => { + sp0.almost_equals(*op0, tolerance) && sp1.almost_equals(*op1, tolerance) + } + (PathEl::CurveTo(sp0, sp1, sp2), PathEl::CurveTo(op0, op1, op2)) => { + sp0.almost_equals(*op0, tolerance) + && sp1.almost_equals(*op1, tolerance) + && sp2.almost_equals(*op2, tolerance) + } + (PathEl::ClosePath, PathEl::ClosePath) => true, + _ => { + debug_assert!( + std::mem::discriminant(self) != std::mem::discriminant(other), + "Missing case for {self:?}, {other:?}?" + ); + false + } + } + } +} + +impl AlmostEqual for &BezPath { + fn almost_equals(self, other: Self, tolerance: f64) -> bool { + let self_el = self.elements(); + let other_el = other.elements(); + if self_el.len() != other_el.len() { + return false; + } + self_el + .iter() + .zip(other_el) + .all(|(e1, e2)| e1.almost_equals(e2, tolerance)) + } +} + +fn round_scalar(v: f64, ndigits: u32) -> f64 { + let mul = 10i32.pow(ndigits) as f64; + (v * mul).round() / mul +} + +fn round_pt(pt: Point, ndigits: u32) -> Point { + Point::new(round_scalar(pt.x, ndigits), round_scalar(pt.y, ndigits)) +} + +fn round_el(el: PathEl, ndigits: u32) -> PathEl { + match el { + PathEl::MoveTo(p) => PathEl::MoveTo(round_pt(p, ndigits)), + PathEl::LineTo(p) => PathEl::LineTo(round_pt(p, ndigits)), + PathEl::QuadTo(p0, p1) => PathEl::QuadTo(round_pt(p0, ndigits), round_pt(p1, ndigits)), + PathEl::CurveTo(p0, p1, p2) => PathEl::CurveTo( + round_pt(p0, ndigits), + round_pt(p1, ndigits), + round_pt(p2, ndigits), + ), + PathEl::ClosePath => PathEl::ClosePath, } +} + +fn round_path(path: BezPath, ndigits: u32) -> BezPath { + path.into_iter().map(|el| round_el(el, ndigits)).collect() +} + +fn round_affine(affine: Affine, ndigits: u32) -> Affine { + let mut coeffs = affine.as_coeffs(); + for mut v in coeffs.iter_mut() { + *v = round_scalar(*v, ndigits); + } + Affine::new(coeffs) +} +// Shift the path such that it begins at 0,0 +fn move_to_origin(path: &BezPath) -> BezPath { let Some(PathEl::MoveTo(first_move)) = path.elements().first() else { - return Err(Error::InvalidPath(path.clone(), "Does not start with MoveTo".to_string())); + return path.clone(); }; let shift = Vec2::new(-first_move.x, -first_move.y); let transform = Affine::translate(shift); let mut path = path.clone(); path.apply_affine(transform); - Ok(path) + path } /// If we thought of the path as a series of vectors to the endpoints of each successive /// drawing command what would it look like? -fn vectors(path: &BezPath) -> Vec { - // BezPath deals in absolutes and may not start at 0 - - let mut vecs = Vec::new(); - let mut last_move = Point::ZERO; // path must start with a move - let mut curr_pos = Point::ZERO; - - for el in path.elements().iter() { - match el { - PathEl::MoveTo(p) => { - last_move = *p; - curr_pos = *p; - } - PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => { - vecs.push(*p - curr_pos); - curr_pos = *p; - } - PathEl::ClosePath => { - vecs.push(last_move - curr_pos); - curr_pos = last_move; - } +/// +/// +struct VecsIter<'a> { + els: &'a [PathEl], + idx: usize, + last_move: Point, + curr_pos: Point, +} + +impl<'a> VecsIter<'a> { + fn new(path: &'a BezPath) -> Self { + Self { + els: path.elements(), + idx: 0, + last_move: Point::ZERO, + curr_pos: Point::ZERO, } } +} + +impl<'a> Iterator for VecsIter<'a> { + type Item = Vec2; - vecs + fn next(&mut self) -> Option { + self.els.get(self.idx).map(|v| { + self.idx += 1; + match v { + PathEl::MoveTo(p) => { + let result = *p - self.curr_pos; + self.last_move = *p; + self.curr_pos = *p; + result + } + PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => { + let result = *p - self.curr_pos; + self.curr_pos = *p; + result + } + // Python treats z as 0,0, we compute the actual vec + PathEl::ClosePath => { + let result = self.last_move - self.curr_pos; + self.curr_pos = self.last_move; + result + } + } + }) + } +} + +/// +fn first_significant( + vecs: VecsIter, + val_fn: impl Fn(Vec2) -> f64, + tolerance: f64, +) -> Option<(usize, Vec2)> { + let tolerance = tolerance * _SIGNIFICANCE_FACTOR; + vecs.enumerate() + .skip(1) // skip initial move + .find(|(_, vec)| val_fn(*vec).abs() > tolerance) + .map(|(idx, vec)| (idx, vec.clone())) +} + +/// +fn first_significant_for_both( + s1_vecs: VecsIter, + s2_vecs: VecsIter, + val_fn: impl Fn(Vec2) -> f64, + tolerance: f64, +) -> Option<(usize, Vec2, Vec2)> { + let tolerance = _SIGNIFICANCE_FACTOR * tolerance; + s1_vecs + .zip(s2_vecs) + .enumerate() + .skip(1) // skip initial move + .find_map(|(idx, (vec1, vec2))| { + if val_fn(vec1).abs() > tolerance && val_fn(vec2).abs() > tolerance { + Some((idx, vec1, vec2)) + } else { + None + } + }) +} + +/// +fn angle(v: Vec2) -> f64 { + v.y.atan2(v.x) +} + +/// +fn affine_vec_to_vec(from: Vec2, to: Vec2) -> Affine { + // rotate initial to have the same angle as target (may have different magnitude) + let angle = angle(to) - angle(from); + let affine = Affine::rotate(angle); + let vec = (affine * from.to_point()).to_vec2(); + + // scale to target magnitude + let scale = if !vec.hypot().almost_equals(0.0, _ALMOST_EQUAL_TOLERANCE) { + to.hypot() / vec.hypot() + } else { + 0.0 + }; + affine * Affine::scale(scale) } /// Build a version of shape that will compare == to other shapes even if offset, scaled, rotated, etc. -/// +/// /// Intended use is to normalize multiple shapes to identify opportunity for reuse. /// -/// +/// /// At time of writing does *not* produce the same result for equivalent shapes with different point order /// or drawing commands. -pub fn normalize(path: &BezPath) -> Result { +pub fn normalize(path: &BezPath, tolerance: f64) -> BezPath { // Always start at 0,0 - let path = move_to_origin(path)?; + let mut path = move_to_origin(path); + + // Normalize first activity to [1 0]; eliminates rotation and uniform scaling + if let Some((_, vec_first)) = first_significant(VecsIter::new(&path), Vec2::hypot, tolerance) { + if !vec_first.almost_equals(X_BASIS, _ALMOST_EQUAL_TOLERANCE) { + path.apply_affine(affine_vec_to_vec(vec_first, X_BASIS)); + } + } + + // Normalize first y activity to 1.0; eliminates mirroring and non-uniform scaling + if let Some((_, vecy)) = first_significant(VecsIter::new(&path), |vec| vec.y, tolerance) { + if !vecy.y.almost_equals(1.0, _ALMOST_EQUAL_TOLERANCE) { + path.apply_affine(Affine::scale_non_uniform(1.0, 1.0 / vecy.y)); + } + } + + path +} + +/// s +fn first_move(path: &BezPath) -> Option { + path.elements().first().and_then(|el| { + if let PathEl::MoveTo(p) = el { + Some(p.to_owned()) + } else { + None + } + }) +} - // Switch to vectors, which are intrinsically relative - let path = vectors(&path); +/// +fn nth_vector(path: &BezPath, n: usize) -> Option { + VecsIter::new(path).skip(n).next() +} - // TODO: Normalize first activity to [1 0]; eliminates rotation and uniform scaling - - // TODO: Normalize first y activity to 1.0; eliminates mirroring and non-uniform scaling +fn try_affine_between(affine: &Affine, s1: &BezPath, s2: &BezPath, tolerance: f64) -> bool { + // TODO we could just walk along comparing elements rather than cloning the whole path + let mut s1_prime = s1.clone(); + s1_prime.apply_affine(*affine); + eprintln!( + "{affine:?} works? {}", + s1_prime.almost_equals(s2, tolerance) + ); + s1_prime.almost_equals(s2, tolerance) +} - todo!() +/// Combines affines in logical order, e.g. "do [0] then [1] then [2]". +/// +/// Using * you end up writing everything backwards, e.g. to move then rotate +/// you would rotate * move (rotate *of* move) +/// +/// +fn combine_ltr(affines: &[Affine]) -> Affine { + affines + .iter() + .rev() + .copied() + .reduce(|acc, e| acc * e) + .unwrap_or(Affine::IDENTITY) +} + +/// Returns an affine that turns s1 into s2 or None if no solution was found. +/// +/// Intended use is to call this only when the normalized versions of the shapes +/// are the same, in which case finding a solution is typical. +/// +/// +pub fn affine_between(s1: &BezPath, s2: &BezPath, tolerance: f64) -> Option { + // Easy mode? + if s1.elements().len() != s2.elements().len() { + return None; + } + if s1.almost_equals(s2, tolerance) { + return Some(Affine::IDENTITY); + } + + // Just move to the same start point? + let (Some(s1_move), Some(s2_move)) = (first_move(s1), first_move(s2)) else { + warn!("At least one input does not start with a move"); + return None + }; + let affine = Affine::translate(s2_move - s1_move); + if try_affine_between(&affine, s1, s2, tolerance) { + return Some(affine); // TODO: round + } + + // Align the first edge with a significant x part. + // Fixes rotation, x-scale, and uniform scaling. + let Some((s2_vec1x_idx, s2_vec1x)) = first_significant(VecsIter::new(s2), |v| v.x, tolerance) else { + // bail out if we find no first edge with significant x part + // https://github.com/googlefonts/picosvg/issues/246 + eprintln!("no first x-part"); + return None + }; + let Some(s1_vec1) = nth_vector(s1, s2_vec1x_idx) else { + eprintln!("s1_vec1 at [{s2_vec1x_idx}]"); + return None; + }; + + let s1_to_origin = Affine::translate(-s1_move.to_vec2()); + let s2_to_origin = Affine::translate(-s2_move.to_vec2()); + let s1_vec1_to_s2_vec1x = affine_vec_to_vec(s1_vec1, s2_vec1x); + + // Move to s2 start + let origin_to_s2 = Affine::translate(s2_move.to_vec2()); + + let affine = combine_ltr(&[s1_to_origin, s1_vec1_to_s2_vec1x, origin_to_s2]); + if try_affine_between(&affine, s1, s2, tolerance) { + return Some(affine); // TODO: round + } + + // Could be non-uniform scaling and/or mirroring + // Make the aligned edge the x axis then align the first edge with a significant y part. + + // Rotate first edge to lie on x axis + let s2_vec1_angle = angle(s2_vec1x); + let rotate_s2vec1_onto_x = Affine::rotate(-s2_vec1_angle); + let rotate_s2vec1_off_x = Affine::rotate(s2_vec1_angle); + + let affine = s1_to_origin * s1_vec1_to_s2_vec1x * rotate_s2vec1_onto_x; + let mut s1_prime = s1.clone(); + s1_prime.apply_affine(affine); + + let affine = s2_to_origin * rotate_s2vec1_onto_x; + let mut s2_prime = s2.clone(); + s2_prime.apply_affine(affine); + + // The first vector we aligned now lies on the x axis + // Find and align the first vector that heads off into y for both + if let Some((idx, s1_vecy, s2_vecy)) = first_significant_for_both( + VecsIter::new(&s1_prime), + VecsIter::new(&s2_prime), + |v| v.y, + tolerance, + ) { + let affine = combine_ltr(&[ + s1_to_origin, + s1_vec1_to_s2_vec1x, + rotate_s2vec1_onto_x, // lie vec1 along x axis + Affine::scale_non_uniform(1.0, s2_vecy.y / s1_vecy.y), // scale first y-vectors to match; x-parts should already match + rotate_s2vec1_off_x, // restore the rotation we removed + origin_to_s2, // drop into final position + ]); + if try_affine_between(&affine, s1, s2, tolerance) { + return Some(affine); // TODO: round + } + } + + // If we still aren't the same give up + return None; } #[cfg(test)] mod tests { - use kurbo::{BezPath, Vec2}; + use kurbo::{Affine, BezPath, Point, Vec2}; - use crate::reuse::vectors; + use crate::reuse::{affine_between, first_significant, round_affine, round_path, VecsIter}; - use super::move_to_origin; + use super::{move_to_origin, normalize}; fn sample_triangle() -> BezPath { BezPath::from_svg("M5,5 L10,0 L10,10 Z").unwrap() } + fn sample_rect(at: impl Into, width: f64, height: f64) -> BezPath { + let mut path = BezPath::new(); + let at = at.into(); + path.move_to(at); + path.line_to(at + (width, 0.0)); + path.line_to(at + (0.0, height)); + path.line_to(at + (-width, 0.0)); + path.close_path(); + path + } + #[test] fn simple_move_to_origin() { let original = sample_triangle(); - assert_eq!( - "M0 0L5 -5L5 5Z", - move_to_origin(&original).unwrap().to_svg() - ); + assert_eq!("M0,0 L5,-5 L5,5 Z", move_to_origin(&original).to_svg()); } #[test] fn vecs_ing_triangle() { assert_eq!( vec![ + Vec2::new(5.0, 5.0), Vec2::new(5.0, -5.0), Vec2::new(0.0, 10.0), Vec2::new(-5.0, -5.0), ], - vectors(&sample_triangle()) + VecsIter::new(&sample_triangle()).collect::>() ); } + + // + #[test] + fn vecs_ing_box() { + assert_eq!( + vec![ + Vec2::new(10.0, 10.0), + Vec2::new(10.0, 0.0), + Vec2::new(0.0, 10.0), + Vec2::new(-10.0, 0.0), + Vec2::new(0.0, -10.0), + ], + VecsIter::new(&BezPath::from_svg("M10,10 h10 v10 h-10 z").unwrap()).collect::>() + ); + } + + // + #[test] + fn vecs_ing_arc_x() { + // arc from 0,0 to 2,0. Apex and farthest point at 1,0.5 + // vectors formed by start => farthest, farthest => end + assert_eq!( + vec![ + Vec2::new(0.0, 0.0), + Vec2::new(2.0, 0.0), + Vec2::new(0.0, 0.5), + ], + VecsIter::new(&BezPath::from_svg("M0,0 a 1 0.5 0 1 1 2,0").unwrap()) + .collect::>() + ); + } + + // + #[test] + fn vecs_ing_arc_y() { + // arc from 0,0 to 0,2. Apex and farthest point at 0,0.5 + // vectors formed by start => farthest, farthest => end + assert_eq!( + vec![ + Vec2::new(0.0, 0.0), + Vec2::new(0.5, 0.0), + Vec2::new(0.0, 2.0), + ], + VecsIter::new(&BezPath::from_svg("M0,0 a 0.5 1 0 1 1 0,2").unwrap()) + .collect::>() + ); + } + + // Arc from Noto Emoji that was resulting in sqrt of a very small negative + // + #[test] + fn vecs_ing_arc_small_value() { + assert_eq!( + vec![ + Vec2::new(0.0, 0.0), + Vec2::new(-3.5, 0.0), + Vec2::new(0.0, 1.73), + Vec2::new(3.5, 0.0), + Vec2::new(0.0, 1.73), + Vec2::new(0.0, 0.0), + ], + VecsIter::new( + &BezPath::from_svg("M0,0 a1.75 1.73 0 1 1 -3.5,0 a1.75 1.73 0 1 1 3.5,0 z") + .unwrap() + ) + .collect::>() + ); + } + + // + #[test] + fn vecs_ing_arc_from_openmoji() { + assert_eq!( + vec![ + Vec2::new(11.011, 11.0), + Vec2::new(49.989000000000004, 0.0), + Vec2::new(0.0, 0.0), + Vec2::new(0.0, 0.0), + Vec2::new(0.0, 49.767), + Vec2::new(0.0, 0.0), + ], + VecsIter::new( + &BezPath::from_svg("M11.011,11 L61,11 A0 0 0 0 1 61,11 L61,60.767 Z").unwrap() + ) + .collect::>() + ); + } + + // Our expected differs from Python as it uses absolute rather than relative. + // + // + #[test] + fn normalize_triangle() { + let input = BezPath::from_svg("M-1,-1 L 0,1 L 1, -1 z").unwrap(); + let expected_norm = "M0,0 L1,-0 L0.4,1 Z"; + let actual_norm = round_path(normalize(&input, 0.1), 4); + assert_eq!(expected_norm, actual_norm.to_svg()); + } + + // Example from Noto Emoji that caused problems in Python + // Expected differs from python as we do the math slightly differently; shapes are very similar. + // + // + #[test] + fn normalize_noto_emoji_eyes() { + let input = BezPath::from_svg("M44.67,45.94 L44.67,45.94 C40.48,45.94 36.67,49.48 36.67,55.36 C36.67,61.24 40.48,64.77 44.67,64.77 L44.67,64.77 C48.86,64.77 52.67,61.23 52.67,55.36 C52.67,49.49 48.86,45.94 44.67,45.94 Z").unwrap(); + let expected_norm = "M0,0 L0,0 C0.2195,-0.262 0.6374,-0.3123 1,-0 C1.3626,0.3123 1.3808,0.738 1.1613,1 L1.1613,1 C0.9419,1.262 0.524,1.3123 0.162,1.0005 C-0.2001,0.6888 -0.2195,0.262 0,0 Z"; + let actual_norm = round_path(normalize(&input, 0.1), 4); + assert_eq!(expected_norm, actual_norm.to_svg()); + } + + #[test] + fn first_significant_skips_move() { + let path = sample_rect((20.0, 20.0), 100.0, 20.0); + assert_eq!( + Some((1, Vec2::new(100.0, 0.0))), + first_significant(VecsIter::new(&path), |e| e.hypot(), 0.01) + ); + } + + fn assert_no_affine_between(s1: &BezPath, s2: &BezPath, tolerance: f64) { + assert_ne!( + normalize(s1, tolerance).to_svg(), + normalize(s2, tolerance).to_svg() + ); + assert_eq!(None, affine_between(s1, s2, tolerance)); + } + + fn assert_affine_between(s1: &BezPath, s2: &BezPath, expected_affine: Affine, tolerance: f64) { + // should normalize the same if we can affine betwee + assert_eq!( + round_path(normalize(s1, tolerance), 4).to_svg(), + round_path(normalize(s2, tolerance), 4).to_svg() + ); + + assert_eq!( + Some(expected_affine), + affine_between(s1, s2, tolerance).map(|a| round_affine(a, 4)), + "wrong affine between:\ns1: {s1:?}\ns2: {s2:?}" + ); + } + + // + #[test] + fn no_affine_between_rect_and_circle() { + let rect = sample_rect((1.0, 1.0), 1.0, 1.0); + // circle from SVGCircle(r=1).as_path().arcs_to_cubics().round_floats(2).d + let circle = BezPath::from_svg("M1,0 C1,0.55 0.55,1 0,1 C-0.55,1 -1,0.55 -1,0 C-1,-0.55 -0.55,-1 0,-1 C0.55,-1 1,-0.55 1,0 Z").unwrap(); + assert_no_affine_between(&rect, &circle, 32.0); + } + + // + #[test] + fn affine_between_rect_and_itself() { + let rect = sample_rect((1.0, 1.0), 1.0, 1.0); + assert_affine_between(&rect, &rect, Affine::IDENTITY, 0.01); + } + + // + #[test] + fn affine_between_offset_rect() { + let rect1 = sample_rect((0.0, 1.0), 1.0, 1.0); + let rect2 = sample_rect((1.0, 0.0), 1.0, 1.0); + assert_affine_between(&rect1, &rect2, Affine::translate((1.0, -1.0)), 0.01); + } + + // + #[test] + fn affine_between_rects1() { + let rect1 = sample_rect((20.0, 20.0), 100.0, 20.0); + let rect2 = sample_rect((40.0, 30.0), 60.0, 20.0); + assert_affine_between( + &rect1, + &rect2, + Affine::new([0.6, 0.0, 0.0, 1.0, 28.0, 10.0]), + 0.01, + ); + } + + // + #[test] + fn affine_between_clock_circles() { + todo!() + } + + // + #[test] + fn affine_between_real_example() { + todo!() + } + + // + #[test] + fn affine_between_mirrored_triangles() { + todo!() + } + + // + #[test] + fn affine_between_rotated_scaled_triangles() { + todo!() + } + + // + #[test] + fn affine_between_square_and_rect() { + todo!() + } + + // + #[test] + fn affine_between_mirrored_squares() { + todo!() + } + + // + #[test] + fn affine_between_noto_emoji_shapes() { + todo!() + } + + // + #[test] + fn affine_between_noto_emoji_eyes() { + todo!() + } + + // + #[test] + fn affine_between_circles() { + todo!() + } + + // + #[test] + fn affine_between_rects2() { + todo!() + } + + // + #[test] + fn affine_between_arcs_in_same_dimension() { + todo!() + } + + // + #[test] + fn affine_between_arcs_in_same_dimension2() { + todo!() + } + + // + #[test] + fn affine_between_arcs_in_same_dimension3() { + todo!() + } }