From 8418957e0e512e53aa103a069e413dbe595c8a5a Mon Sep 17 00:00:00 2001 From: rsheeter Date: Fri, 25 Sep 2020 22:20:40 -0700 Subject: [PATCH 01/15] Add ability to convert coords to relative --- src/picosvg/svg_reuse.py | 8 +++++++- src/picosvg/svg_types.py | 43 +++++++++++++++++++++++++++++----------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index 4a60c12..12ddf60 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -37,9 +37,15 @@ def normalize(shape: SVGShape, tolerance: int = DEFAULT_TOLERANCE) -> SVGShape: Intended use is to normalize multiple shapes to identify opportunity for reuse.""" shape = dataclasses.replace(shape, id="") x, y = _first_move(shape) + + # Shape is now entirely relative, with first coord at 0,0 shape = ( - shape.as_path().move(-x, -y, inplace=True).round_floats(tolerance, inplace=True) + shape.as_path() + .relative(inplace=True) + .move(-x, -y, inplace=True) + .round_floats(tolerance, inplace=True) ) + return shape diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py index 3565354..03d2473 100644 --- a/src/picosvg/svg_types.py +++ b/src/picosvg/svg_types.py @@ -53,19 +53,32 @@ def _explicit_lines_callback(subpath_start, curr_pos, cmd, args, *_): return ((cmd, args),) -def _relative_to_absolute(curr_pos, cmd, args): - x_coord_idxs, y_coord_idxs = cmd_coords(cmd) - if cmd.islower(): - cmd = cmd.upper() +def _rewrite_coords(cmd_converter, coord_converter, curr_pos, cmd, args): + x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) + desired_cmd = cmd_converter(cmd) + if cmd != desired_cmd: + cmd = desired_cmd args = list(args) # we'd like to mutate 'em for x_coord_idx in x_coord_idxs: - args[x_coord_idx] += curr_pos.x + args[x_coord_idx] += coord_converter(curr_pos.x) for y_coord_idx in y_coord_idxs: - args[y_coord_idx] += curr_pos.y + args[y_coord_idx] += coord_converter(curr_pos.y) return (cmd, tuple(args)) +def _relative_to_absolute(curr_pos, cmd, args): + return _rewrite_coords( + lambda cmd: cmd.upper(), lambda curr_scaler: curr_scaler, curr_pos, cmd, args + ) + + +def _absolute_to_relative(curr_pos, cmd, args): + return _rewrite_coords( + lambda cmd: cmd.lower(), lambda curr_scaler: -curr_scaler, curr_pos, cmd, args + ) + + def _next_pos(curr_pos, cmd, cmd_args): # update current position x_coord_idxs, y_coord_idxs = cmd_coords(cmd) @@ -389,11 +402,9 @@ def move_callback(subpath_start, curr_pos, cmd, args, *_unused): target.walk(move_callback) return target - def absolute(self, inplace=False) -> "SVGPath": - """Returns equivalent path with only absolute commands.""" - - def absolute_callback(subpath_start, curr_pos, cmd, args, *_): - new_cmd, new_cmd_args = _relative_to_absolute(curr_pos, cmd, args) + def _rewrite_path(self, rewrite_fn, inplace) -> "SVGPath": + def rewrite_callback(subpath_start, curr_pos, cmd, args, *_): + new_cmd, new_cmd_args = rewrite_fn(curr_pos, cmd, args) # if we modified cmd to pass *very* close to subpath start snap to it # eliminates issues with not-quite-closed shapes due float imprecision @@ -407,9 +418,17 @@ def absolute_callback(subpath_start, curr_pos, cmd, args, *_): target = self if not inplace: target = copy.deepcopy(self) - target.walk(absolute_callback) + target.walk(rewrite_callback) return target + def absolute(self, inplace=False) -> "SVGPath": + """Returns equivalent path with only absolute commands.""" + return self._rewrite_path(_relative_to_absolute, inplace) + + def relative(self, inplace=False) -> "SVGPath": + """Returns equivalent path with only relative commands.""" + return self._rewrite_path(_absolute_to_relative, inplace) + def explicit_lines(self, inplace=False): """Replace all vertical/horizontal lines with line to (x,y).""" target = self From 74b4adfacaf38f8416c501ad6888aeab21d9ea9e Mon Sep 17 00:00:00 2001 From: rsheeter Date: Tue, 29 Sep 2020 22:54:19 -0700 Subject: [PATCH 02/15] Works for triangles but has trouble with real sample data from Noto --- src/picosvg/geometric_types.py | 7 +- src/picosvg/svg_reuse.py | 198 ++++++++++++++++++++++++++++----- src/picosvg/svg_transform.py | 21 +++- src/picosvg/svg_types.py | 20 ++-- tests/svg_reuse_test.py | 38 ++++++- 5 files changed, 246 insertions(+), 38 deletions(-) diff --git a/src/picosvg/geometric_types.py b/src/picosvg/geometric_types.py index bea6c49..8cb27a5 100644 --- a/src/picosvg/geometric_types.py +++ b/src/picosvg/geometric_types.py @@ -15,10 +15,15 @@ import math from typing import NamedTuple, Optional, Union + _DEFAULT_ALMOST_EQUAL_TOLERENCE = 1e-9 _PointOrVec = Union["Point", "Vector"] +def almost_equal(c1, c2, tolerence=_DEFAULT_ALMOST_EQUAL_TOLERENCE) -> bool: + return abs(c1 - c2) <= tolerence + + class Point(NamedTuple): x: float = 0 y: float = 0 @@ -53,7 +58,7 @@ def round(self, digits: int) -> "Point": def almost_equals( self, other: "Point", tolerence=_DEFAULT_ALMOST_EQUAL_TOLERENCE ) -> bool: - return abs(self.x - other.x) <= tolerence and abs(self.y - other.y) <= tolerence + return almost_equal(self.x, other.x, tolerence) and almost_equal(self.y, other.y, tolerence) class Vector(NamedTuple): diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index 12ddf60..b0efdfb 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -14,65 +14,211 @@ # Functions meant to help discern if shapes are the same or not. +import copy import dataclasses +from math import atan2 +from picosvg.geometric_types import Vector, almost_equal from picosvg.svg_types import SVGShape, SVGPath from typing import Optional, Tuple +from picosvg import svg_meta from picosvg.svg_transform import Affine2D # Number of decimal digits to round floats when normalizing or comparing -DEFAULT_TOLERANCE = 9 +# TODO: hard to get #s to line up well with high tolerance +# TODO: maybe the input svgs should have higher precision? - only 2 decimals on hearts +_DEFAULT_TOLERANCE = 6 +_ROUND_RANGE = range(3, 13) # range of rounds to try -def _first_move(shape: SVGShape) -> Tuple[float, float]: - cmd, args = next(iter(shape.as_path())) +def _first_move(path: SVGPath) -> Tuple[float, float]: + cmd, args = next(iter(path)) if cmd.upper() != "M": - raise ValueError(f"Path for {shape} should start with a move") + raise ValueError(f"Path for {path} should start with a move") return args -def normalize(shape: SVGShape, tolerance: int = DEFAULT_TOLERANCE) -> SVGShape: +def _vectors(path: SVGPath) -> Vector: + for cmd, args in path: + x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) + if cmd.lower() == "z": + return Vector(0., 0.) + yield Vector(args[x_coord_idxs[-1]], args[y_coord_idxs[-1]]) + + +def _nth_vector(path: SVGPath, n: int) -> Vector: + vectors = _vectors(path) + for _ in range(n): + next(vectors) + return next(vectors) + + +def _angle(v: Vector) -> float: + # gives the directional angle of vector (unlike acos) + return atan2(v.y, v.x) + + +def _affine_vec2vec(initial: Vector, target: Vector) -> Affine2D: + affine = Affine2D.identity() + + # rotate initial to have the same angle as target (may have different magnitude) + angle = _angle(target) - _angle(initial) + affine = Affine2D.identity().rotate(angle) + vec = affine.map_vector(initial) + + # scale to target magnitude + s = target.norm() / vec.norm() + + affine = Affine2D.product(Affine2D.identity().scale(s, s), affine) + + return affine + + +# Makes a shape safe for a walk with _affine_callback +def _affine_friendly(shape: SVGShape) -> SVGPath: + path = shape.as_path() + if shape is path: + path = copy.deepcopy(path) + return (path + .relative(inplace=True) + .explicit_lines(inplace=True) + .expand_shorthand(inplace=True)) + + +# Transform all coords in an affine-friendly path +def _affine_callback(affine, subpath_start, curr_pos, cmd, args, *_unused): + x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) + # hard to do things like rotate if we have 1d coords + assert len(x_coord_idxs) == len(y_coord_idxs), f"{cmd}, {args}" + + args = list(args) # we'd like to mutate 'em + for x_coord_idx, y_coord_idx in zip(x_coord_idxs, y_coord_idxs): + if cmd == cmd.upper(): + # for an absolute cmd allow translation: map_point + new_x, new_y = affine.map_point((args[x_coord_idx], args[y_coord_idx])) + else: + # for a relative coord no translate: map_vector + new_x, new_y = affine.map_vector((args[x_coord_idx], args[y_coord_idx])) + + if almost_equal(new_x, 0): + new_x = 0 + if almost_equal(new_y, 0): + new_y = 0 + args[x_coord_idx] = new_x + args[y_coord_idx] = new_y + return ((cmd, args),) + + +def normalize(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape: """Build a version of shape that will compare == to other shapes even if offset. Intended use is to normalize multiple shapes to identify opportunity for reuse.""" - shape = dataclasses.replace(shape, id="") - x, y = _first_move(shape) - # Shape is now entirely relative, with first coord at 0,0 - shape = ( - shape.as_path() - .relative(inplace=True) - .move(-x, -y, inplace=True) - .round_floats(tolerance, inplace=True) - ) + path = _affine_friendly(dataclasses.replace(shape, id="")) + + # Make path relative, with first coord at 0,0 + x, y = _first_move(path) + path.move(-x, -y, inplace=True) + + # By normalizing vector 1 to [1 0] and making first move off y positive we + # normalize away rotation, scale and shear. + vec1 = _nth_vector(path, 1) # ignore M 0,0 + path.walk(lambda *args: _affine_callback(_affine_vec2vec(vec1, Vector(1, 0)), *args)) + + # TODO instead of flipping normalize vec2 to [0 1]? + # Would be nice to avoid destroying the initial [1 0] + # If we just compute another affine it probably will wreck that + flip = False + for vec in _vectors(path): + if vec.y != 0: + flip = vec.y < 0 + break + + if flip: + path.walk(lambda *args: _affine_callback(Affine2D.flip_y(), *args)) - return shape + # TODO: what if shapes are the same but different start point + + path.round_floats(tolerance, inplace=True) + return path def affine_between( - s1: SVGShape, s2: SVGShape, tolerance: int = DEFAULT_TOLERANCE + s1: SVGShape, s2: SVGShape, tolerance: int = _DEFAULT_TOLERANCE ) -> Optional[Affine2D]: """Returns the Affine2D to change s1 into s2 or None if no solution was found. - Implementation starting *very* basic, can improve over time. + 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 + """ + def _try_affine(affine, s1, s2): + maybe_match = copy.deepcopy(s1) + maybe_match.walk(lambda *args: _affine_callback(affine, *args)) + return maybe_match.almost_equals(s2, tolerance) + + def _round(affine, s1, s2): + # TODO bsearch? + for i in _ROUND_RANGE: + rounded = affine.round(i) + if _try_affine(rounded, s1, s2): + return rounded + return affine # give up + s1 = dataclasses.replace(s1, id="") s2 = dataclasses.replace(s2, id="") if s1.almost_equals(s2, tolerance): return Affine2D.identity() - s1 = s1.as_path() - s2 = s2.as_path() + s1 = _affine_friendly(s1) + s2 = _affine_friendly(s2) s1x, s1y = _first_move(s1) s2x, s2y = _first_move(s2) - dx = s2x - s1x - dy = s2y - s1y - - s1.move(dx, dy, inplace=True) - - if s1.almost_equals(s2, tolerance): - return Affine2D.identity().translate(dx, dy) + affine = Affine2D.identity().translate(s2x - s1x, s2y - s1y) + if _try_affine(affine, s1, s2): + return affine + + # TODO how to share code with normalize? + + # Normalize first edge. This may leave s1 as the mirror of s2 over that edge. + s1_vec1 = _nth_vector(s1, 1) + s2_vec1 = _nth_vector(s2, 1) + + transforms = [ + # Move to 0,0 + Affine2D.identity().translate(-s1x, -s1y), + # Normalize vector1 + _affine_vec2vec(s1_vec1, s2_vec1), + # Move to s2 start + Affine2D.identity().translate(s2x, s2y) + ] + affine = Affine2D.compose_ltr(transforms) + + # TODO if that doesn't fix vec1 we can give up + # TODO just testing vec2 would tell us if we should try mirroring + if _try_affine(affine, s1, s2): + return _round(affine, s1, s2) + + # Last chance, try to mirror + transforms = ( + # Normalize vector 1 + transforms[:-1] + + [ + # Rotate first edge to lie on y axis + Affine2D.identity().rotate(-_angle(s2_vec1)), + Affine2D.flip_y(), + # Rotate back into position + Affine2D.identity().rotate(_angle(s2_vec1)), + ] + # Move to s2's start point + + transforms[-1:]) + + affine = Affine2D.compose_ltr(transforms) + if _try_affine(affine, s1, s2): + return _round(affine, s1, s2) + + # If we still aren't the same give up return None diff --git a/src/picosvg/svg_transform.py b/src/picosvg/svg_transform.py index e590798..741fff1 100644 --- a/src/picosvg/svg_transform.py +++ b/src/picosvg/svg_transform.py @@ -17,9 +17,10 @@ Focuses on converting to a sequence of affine matrices. """ import collections +from functools import reduce from math import cos, sin, radians, tan import re -from typing import NamedTuple, Tuple +from typing import Iterable, NamedTuple, Tuple from sys import float_info from picosvg.geometric_types import Point, Rect, Vector @@ -48,6 +49,10 @@ class Affine2D(NamedTuple): e: float f: float + @staticmethod + def flip_y(): + return Affine2D._flip_y + @staticmethod def identity(): return Affine2D._identity @@ -145,6 +150,19 @@ def map_vector(self, vec: Tuple[float, float]) -> Vector: x, y = vec return Vector(self.a * x + self.c * y, self.b * x + self.d * y) + @classmethod + def compose_ltr(cls, affines: Iterable["Affine2D"]) -> "Affine2D": + """Creates merged transform equivalent to applying transforms left-to-right order. + + Affines apply like functions - f(g(x)) - so we merge them in reverse order. + """ + return reduce(lambda acc, a: cls.product(a, acc), reversed(affines), cls.identity()) + + + def round(self, digits: int) -> "Affine2D": + return Affine2D(*(round(v, digits) for v in self)) + + @classmethod def rect_to_rect(cls, src: Rect, dst: Rect) -> "Affine2D": """ Return Affine2D set to scale and translate src Rect to dst Rect. @@ -163,6 +181,7 @@ def rect_to_rect(cls, src: Rect, dst: Rect) -> "Affine2D": Affine2D._identity = Affine2D(1, 0, 0, 1, 0, 0) Affine2D._degnerate = Affine2D(0, 0, 0, 0, 0, 0) +Affine2D._flip_y = Affine2D(1, 0, 0, -1, 0, 0) def _fix_rotate(args): diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py index 03d2473..a62e8db 100644 --- a/src/picosvg/svg_types.py +++ b/src/picosvg/svg_types.py @@ -58,6 +58,7 @@ def _rewrite_coords(cmd_converter, coord_converter, curr_pos, cmd, args): desired_cmd = cmd_converter(cmd) if cmd != desired_cmd: cmd = desired_cmd + #if x_coord_idxs or y_coord_idxs: args = list(args) # we'd like to mutate 'em for x_coord_idx in x_coord_idxs: args[x_coord_idx] += coord_converter(curr_pos.x) @@ -79,7 +80,7 @@ def _absolute_to_relative(curr_pos, cmd, args): ) -def _next_pos(curr_pos, cmd, cmd_args): +def _next_pos(curr_pos, cmd, cmd_args) -> Point: # update current position x_coord_idxs, y_coord_idxs = cmd_coords(cmd) new_x, new_y = curr_pos @@ -102,16 +103,17 @@ def _move_endpoint(curr_pos, cmd, cmd_args, new_endpoint): ((cmd, cmd_args),) = _explicit_lines_callback(None, curr_pos, cmd, cmd_args) x_coord_idxs, y_coord_idxs = cmd_coords(cmd) - cmd_args = list(cmd_args) # we'd like to mutate - new_x, new_y = new_endpoint - if cmd.islower(): - new_x = new_x - curr_pos.x - new_y = new_y - curr_pos.y + if x_coord_idxs or y_coord_idxs: + cmd_args = list(cmd_args) # we'd like to mutate + new_x, new_y = new_endpoint + if cmd.islower(): + new_x = new_x - curr_pos.x + new_y = new_y - curr_pos.y - cmd_args[x_coord_idxs[-1]] = new_x - cmd_args[y_coord_idxs[-1]] = new_y + cmd_args[x_coord_idxs[-1]] = new_x + cmd_args[y_coord_idxs[-1]] = new_y - return cmd, cmd_args + return cmd, tuple(cmd_args) # Subset of https://www.w3.org/TR/SVG11/painting.html diff --git a/tests/svg_reuse_test.py b/tests/svg_reuse_test.py index 564dd36..6178134 100644 --- a/tests/svg_reuse_test.py +++ b/tests/svg_reuse_test.py @@ -54,6 +54,37 @@ ), Affine2D.identity().translate(16, 0), ), + # Triangles facing one another, same size + ( + SVGPath( + d="m60,64 -50,-32 0,30 z" + ), + SVGPath( + d="m68,64 50,-32 0,30 z" + ), + Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), + ), + # Triangles, different rotation, different size + ( + SVGPath( + d="m50,100 -48,-75 81,0 z" + ), + SVGPath( + d="m70,64 50,-32 0,54 z" + ), + Affine2D(a=-0.0, b=0.6667, c=-0.6667, d=-0.0, e=136.6667, f=30.6667), + ), + # Top heart, bottom heart from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f970 + # The heart is actually several parts, here just the main outline + ( + SVGPath( + d="M110.78,1.22c-7.06,2.83-7.68,10.86-7.68,10.86s-4.63-5.01-10.9-2.9c-7.53,2.54-11.32,10.62-5.22,19.34c6.98,9.97,29.38,12.81,29.38,12.81s12.53-16.37,10.79-29.35C125.74,1.43,116.12-0.92,110.78,1.22z" + ), + SVGPath( + d="M116.31,96.26c-6.38-4.14-13.3-0.02-13.3-0.02s1.43-6.67-3.91-10.58c-6.41-4.7-15.2-3.13-18.81,6.88c-4.13,11.45,6.46,31.39,6.46,31.39s20.6,0.81,30.2-8.09C124.76,108.61,121.13,99.39,116.31,96.26z" + ), + Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), + ), ], ) def test_svg_reuse(s1, s2, expected_affine): @@ -63,4 +94,9 @@ def test_svg_reuse(s1, s2, expected_affine): else: assert normalize(s1) != normalize(s2) - assert affine_between(s1, s2) == expected_affine + affine = affine_between(s1, s2) + if expected_affine: + assert affine + # Round because we've seen issues with different test environments when overly fine + affine = affine.round(4) + assert affine == expected_affine From 78e4acbcff5fadee067b0b3342492c0d391ac050 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Mon, 12 Oct 2020 20:18:54 -0700 Subject: [PATCH 03/15] More interesting (and failing) tests --- tests/svg_reuse_test.py | 42 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/tests/svg_reuse_test.py b/tests/svg_reuse_test.py index 6178134..b876aea 100644 --- a/tests/svg_reuse_test.py +++ b/tests/svg_reuse_test.py @@ -74,14 +74,48 @@ ), Affine2D(a=-0.0, b=0.6667, c=-0.6667, d=-0.0, e=136.6667, f=30.6667), ), + # Tears from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f602 + ( + SVGPath( + d="M100.69,57.09c0,0,17,0.47,23.79,13c2.84,5.26,3,14-2.88,17.19c-7.11,3.91-13.78-1.64-14.63-7.56C104.68,63.66,100.69,57.09,100.69,57.09z" + ), + SVGPath( + d="M27.31,57.09c0,0-17,0.47-23.79,13C0.68,75.3,0.57,84,6.4,87.23c7.11,3.91,13.78-1.64,14.63-7.56C23.32,63.66,27.31,57.09,27.31,57.09z" + ), + Affine2D.identity(), + ), + # Top heart, bottom heart from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f970 + # This path is exported with 2 decimal places + ( + SVGPath( + d="M111.15,39.48c-2.24-0.61-22.59-6.5-25.8-18.44c-0.67-2.49-0.28-5.21,1.06-7.48c1.34-2.27,3.44-3.85,5.92-4.46c0.89-0.24,1.77-0.35,2.64-0.35c2.63,0,5.1,1.02,6.95,2.88l2.42,2.44l0.92-3.31c0.93-3.33,3.67-6.04,6.98-6.9c0.86-0.23,1.73-0.34,2.6-0.34c1.72,0,3.42,0.46,4.92,1.32c2.28,1.31,3.9,3.41,4.56,5.92C127.53,22.69,112.88,37.76,111.15,39.48z" + ), + SVGPath( + d="M88.42,121.72c-1.15-2.01-11.26-20.36-5.15-30.95c1.71-2.96,4.93-4.8,8.42-4.8c1.71,0,3.37,0.46,4.81,1.33c3.07,1.78,4.95,5.02,4.93,8.47l-0.01,3.44l3-1.69c1.45-0.82,3.12-1.25,4.82-1.25c1.73,0,3.42,0.45,4.87,1.31c2.27,1.33,3.89,3.43,4.57,5.93c0.68,2.5,0.34,5.1-0.95,7.32c-6.1,10.59-26.76,10.89-29.1,10.89L88.42,121.72z" + ), + Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), + ), + # Top heart, mid/left heart from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f970 + # This path is exported with 2 decimal places + ( + SVGPath( + d="M111.15,39.48c-2.24-0.61-22.59-6.5-25.8-18.44c-0.67-2.49-0.28-5.21,1.06-7.48c1.34-2.27,3.44-3.85,5.92-4.46c0.89-0.24,1.77-0.35,2.64-0.35c2.63,0,5.1,1.02,6.95,2.88l2.42,2.44l0.92-3.31c0.93-3.33,3.67-6.04,6.98-6.9c0.86-0.23,1.73-0.34,2.6-0.34c1.72,0,3.42,0.46,4.92,1.32c2.28,1.31,3.9,3.41,4.56,5.92C127.53,22.69,112.88,37.76,111.15,39.48z" + ), + SVGPath( + d="M33.95,97.7c-2.33-0.16-23.23-1.86-28.7-12.83c-1.14-2.28-1.3-5-0.45-7.47c0.85-2.47,2.58-4.42,4.87-5.5c1.43-0.71,2.91-1.06,4.43-1.06c1.92,0,3.78,0.56,5.37,1.63l2.86,1.91l0.24-3.43c0.24-3.41,2.37-6.58,5.41-8.07c1.4-0.69,2.87-1.04,4.39-1.04c1.05,0,2.09,0.17,3.1,0.5c2.47,0.82,4.45,2.54,5.59,4.84C46.54,78.14,35.31,95.65,33.95,97.7z" + ), + Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), + ), # Top heart, bottom heart from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f970 - # The heart is actually several parts, here just the main outline + # This path is custom exported at high precision, at time of writing Noto svgs have 2 decimal places + # This is an interesting example because one of the hearts has extra path segments + # so a comparison of same # of very similar segments will fail. ( SVGPath( - d="M110.78,1.22c-7.06,2.83-7.68,10.86-7.68,10.86s-4.63-5.01-10.9-2.9c-7.53,2.54-11.32,10.62-5.22,19.34c6.98,9.97,29.38,12.81,29.38,12.81s12.53-16.37,10.79-29.35C125.74,1.43,116.12-0.92,110.78,1.22z" + d="M111.149414,39.480957c-2.240234-0.61377-22.592773-6.500488-25.797852-18.4375c-0.667969-2.485352-0.282227-5.212402,1.058594-7.481934c1.339844-2.268066,3.442383-3.852539,5.920898-4.461914c0.893555-0.237793,1.769531-0.353516,2.640625-0.353516c2.633789,0,5.101562,1.024414,6.949219,2.884277l2.423828,2.439453l0.921875-3.312988c0.926758-3.329102,3.665039-6.037109,6.976562-6.898438c0.859375-0.226562,1.731445-0.340332,2.59668-0.340332c1.716797,0,3.416992,0.455078,4.916016,1.316406c2.277344,1.305664,3.896484,3.40625,4.560547,5.915527C127.529297,22.690918,112.875,37.760254,111.149414,39.480957z" ), SVGPath( - d="M116.31,96.26c-6.38-4.14-13.3-0.02-13.3-0.02s1.43-6.67-3.91-10.58c-6.41-4.7-15.2-3.13-18.81,6.88c-4.13,11.45,6.46,31.39,6.46,31.39s20.6,0.81,30.2-8.09C124.76,108.61,121.13,99.39,116.31,96.26z" + d="M88.421875,121.71875c-1.148438-2.014648-11.257812-20.357422-5.149414-30.950195c1.707031-2.960938,4.931641-4.800781,8.415039-4.800781c1.709961,0,3.373047,0.459961,4.811523,1.330078c3.067383,1.77832,4.945312,5.017578,4.933594,8.47168l-0.011719,3.439453l2.995117-1.691406c1.449219-0.818359,3.117188-1.250977,4.824219-1.250977c1.733398,0,3.417969,0.452148,4.873047,1.305664c2.271484,1.325195,3.892578,3.431641,4.569336,5.933594c0.679688,2.504883,0.342773,5.103516-0.946289,7.321289c-6.098633,10.587891-26.760742,10.893555-29.09668,10.893555L88.421875,121.71875z" ), Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), ), @@ -96,7 +130,7 @@ def test_svg_reuse(s1, s2, expected_affine): affine = affine_between(s1, s2) if expected_affine: - assert affine + assert affine, f"No affine found between {s1} and {s2}. Expected {expected_affine}" # Round because we've seen issues with different test environments when overly fine affine = affine.round(4) assert affine == expected_affine From af5b252374a74534035049bd1c854f4a2c125a99 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Mon, 12 Oct 2020 20:19:09 -0700 Subject: [PATCH 04/15] Improve on reuse of shapes, non-uniform scaling, rotation, mirroring, etc. Address some review comments. --- src/picosvg/geometric_types.py | 4 +- src/picosvg/svg_meta.py | 2 +- src/picosvg/svg_path_iter.py | 8 +- src/picosvg/svg_reuse.py | 207 +++++++++++++++++++++++---------- src/picosvg/svg_transform.py | 12 +- src/picosvg/svg_types.py | 23 +++- tests/svg_reuse_test.py | 74 +++--------- 7 files changed, 196 insertions(+), 134 deletions(-) diff --git a/src/picosvg/geometric_types.py b/src/picosvg/geometric_types.py index 8cb27a5..c2c9c50 100644 --- a/src/picosvg/geometric_types.py +++ b/src/picosvg/geometric_types.py @@ -58,7 +58,9 @@ def round(self, digits: int) -> "Point": def almost_equals( self, other: "Point", tolerence=_DEFAULT_ALMOST_EQUAL_TOLERENCE ) -> bool: - return almost_equal(self.x, other.x, tolerence) and almost_equal(self.y, other.y, tolerence) + return almost_equal(self.x, other.x, tolerence) and almost_equal( + self.y, other.y, tolerence + ) class Vector(NamedTuple): diff --git a/src/picosvg/svg_meta.py b/src/picosvg/svg_meta.py index fea4c73..0614686 100644 --- a/src/picosvg/svg_meta.py +++ b/src/picosvg/svg_meta.py @@ -135,7 +135,7 @@ def parse_css_declarations( output: MutableMapping[str, Any], property_names: Optional[Container[str]] = None, ) -> str: - """ Parse CSS declaration list into {property: value} dict. + """Parse CSS declaration list into {property: value} dict. Args: style: CSS declaration list without the enclosing braces, diff --git a/src/picosvg/svg_path_iter.py b/src/picosvg/svg_path_iter.py index 892a680..375a97b 100644 --- a/src/picosvg/svg_path_iter.py +++ b/src/picosvg/svg_path_iter.py @@ -35,11 +35,11 @@ def _explode_cmd(args_per_cmd, cmd, args): def parse_svg_path(svg_path: str, exploded=False): """Parses an svg path. - Exploded means when params repeat each the command is reported as - if multiplied. For example "M1,1 2,2 3,3" would report as three - separate steps when exploded. + Exploded means when params repeat each the command is reported as + if multiplied. For example "M1,1 2,2 3,3" would report as three + separate steps when exploded. - Yields tuples of (cmd, (args)).""" + Yields tuples of (cmd, (args)).""" command_tuples = [] parts = _CMD_RE.split(svg_path)[1:] for i in range(0, len(parts), 2): diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index b0efdfb..3a5b734 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -16,10 +16,11 @@ import copy import dataclasses +from itertools import islice from math import atan2 from picosvg.geometric_types import Vector, almost_equal from picosvg.svg_types import SVGShape, SVGPath -from typing import Optional, Tuple +from typing import Generator, Iterable, Optional, Tuple from picosvg import svg_meta from picosvg.svg_transform import Affine2D @@ -28,6 +29,7 @@ # TODO: hard to get #s to line up well with high tolerance # TODO: maybe the input svgs should have higher precision? - only 2 decimals on hearts _DEFAULT_TOLERANCE = 6 +_DEFAULT_LEVEL = 2 _ROUND_RANGE = range(3, 13) # range of rounds to try @@ -38,19 +40,69 @@ def _first_move(path: SVGPath) -> Tuple[float, float]: return args -def _vectors(path: SVGPath) -> Vector: +def normalize( + shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE, level: int = _DEFAULT_LEVEL +) -> SVGShape: + return globals()[f"normalize{level}"](shape, tolerance) + + +def affine_between( + s1: SVGShape, + s2: SVGShape, + tolerance: int = _DEFAULT_TOLERANCE, + level: int = _DEFAULT_LEVEL, +) -> Optional[Affine2D]: + return globals()[f"affine_between{level}"](s1, s2, tolerance) + + +def normalize1(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape: + """Build a version of shape that will compare == to other shapes even if offset. + Intended use is to normalize multiple shapes to identify opportunity for reuse.""" + shape = dataclasses.replace(shape, id="") + path = shape.as_path() + x, y = _first_move(path) + return path.move(-x, -y, inplace=True).round_floats(tolerance, inplace=True) + + +def affine_between1( + s1: SVGShape, s2: SVGShape, tolerance: int = _DEFAULT_TOLERANCE +) -> Optional[Affine2D]: + """Returns the Affine2D to change s1 into s2 or None if no solution was found. + Implementation starting *very* basic, can improve over time. + """ + s1 = dataclasses.replace(s1, id="") + s2 = dataclasses.replace(s2, id="") + + if s1.almost_equals(s2, tolerance): + return Affine2D.identity() + + s1 = s1.as_path() + s2 = s2.as_path() + + s1x, s1y = _first_move(s1) + s2x, s2y = _first_move(s2) + dx = s2x - s1x + dy = s2y - s1y + + s1.move(dx, dy, inplace=True) + + if s1.almost_equals(s2, tolerance): + return Affine2D.identity().translate(dx, dy) + + return None + + +def _vectors(path: SVGPath) -> Generator[Vector, None, None]: for cmd, args in path: x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) if cmd.lower() == "z": - return Vector(0., 0.) - yield Vector(args[x_coord_idxs[-1]], args[y_coord_idxs[-1]]) + yield Vector(0.0, 0.0) + else: + yield Vector(args[x_coord_idxs[-1]], args[y_coord_idxs[-1]]) def _nth_vector(path: SVGPath, n: int) -> Vector: - vectors = _vectors(path) - for _ in range(n): - next(vectors) - return next(vectors) + return next(islice(_vectors(path), n, n + 1)) def _angle(v: Vector) -> float: @@ -67,22 +119,32 @@ def _affine_vec2vec(initial: Vector, target: Vector) -> Affine2D: vec = affine.map_vector(initial) # scale to target magnitude - s = target.norm() / vec.norm() + s = 0 + if vec.norm() != 0: + s = target.norm() / vec.norm() - affine = Affine2D.product(Affine2D.identity().scale(s, s), affine) + affine = Affine2D.compose_ltr((affine, Affine2D.identity().scale(s, s))) return affine +def _first_y(vectors: Iterable[Vector]) -> Optional[Vector]: + for idx, vec in enumerate(vectors): + if idx > 0 and abs(vec.y) > 0.1: + return vec + return None + + # Makes a shape safe for a walk with _affine_callback def _affine_friendly(shape: SVGShape) -> SVGPath: path = shape.as_path() if shape is path: path = copy.deepcopy(path) - return (path - .relative(inplace=True) + return ( + path.relative(inplace=True) .explicit_lines(inplace=True) - .expand_shorthand(inplace=True)) + .expand_shorthand(inplace=True) + ) # Transform all coords in an affine-friendly path @@ -109,7 +171,7 @@ def _affine_callback(affine, subpath_start, curr_pos, cmd, args, *_unused): return ((cmd, args),) -def normalize(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape: +def normalize2(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape: """Build a version of shape that will compare == to other shapes even if offset. Intended use is to normalize multiple shapes to identify opportunity for reuse.""" @@ -120,30 +182,26 @@ def normalize(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape: x, y = _first_move(path) path.move(-x, -y, inplace=True) - # By normalizing vector 1 to [1 0] and making first move off y positive we - # normalize away rotation, scale and shear. + # Normlize vector 1 to [1 0]; eliminates rotation and uniform scaling vec1 = _nth_vector(path, 1) # ignore M 0,0 - path.walk(lambda *args: _affine_callback(_affine_vec2vec(vec1, Vector(1, 0)), *args)) - - # TODO instead of flipping normalize vec2 to [0 1]? - # Would be nice to avoid destroying the initial [1 0] - # If we just compute another affine it probably will wreck that - flip = False - for vec in _vectors(path): - if vec.y != 0: - flip = vec.y < 0 - break + affine1 = _affine_vec2vec(vec1, Vector(1, 0)) + path.walk(lambda *args: _affine_callback(affine1, *args)) - if flip: - path.walk(lambda *args: _affine_callback(Affine2D.flip_y(), *args)) + # Scale first y movement to 1.0 + vecy = _first_y(_vectors(path)) + if vecy and not almost_equal(vecy.y, 1.0): + affine2 = Affine2D.identity().scale(1, 1 / vecy.y) + path.walk(lambda *args: _affine_callback(affine2, *args)) # TODO: what if shapes are the same but different start point + # TODO: what if shapes are the same but different drawing cmds + # This DOES happen in Noto; extent unclear path.round_floats(tolerance, inplace=True) return path -def affine_between( +def affine_between2( s1: SVGShape, s2: SVGShape, tolerance: int = _DEFAULT_TOLERANCE ) -> Optional[Affine2D]: """Returns the Affine2D to change s1 into s2 or None if no solution was found. @@ -152,10 +210,14 @@ def affine_between( are the same, in which case finding a solution is typical """ + + def _apply_affine(affine, s): + s_prime = copy.deepcopy(s) + s_prime.walk(lambda *args: _affine_callback(affine, *args)) + return s_prime + def _try_affine(affine, s1, s2): - maybe_match = copy.deepcopy(s1) - maybe_match.walk(lambda *args: _affine_callback(affine, *args)) - return maybe_match.almost_equals(s2, tolerance) + return _apply_affine(affine, s1).almost_equals(s2, tolerance) def _round(affine, s1, s2): # TODO bsearch? @@ -181,44 +243,61 @@ def _round(affine, s1, s2): if _try_affine(affine, s1, s2): return affine - # TODO how to share code with normalize? - - # Normalize first edge. This may leave s1 as the mirror of s2 over that edge. + # Normalize first edge. + # Fixes rotation, x-scale, and uniform scaling. s1_vec1 = _nth_vector(s1, 1) s2_vec1 = _nth_vector(s2, 1) - transforms = [ - # Move to 0,0 - Affine2D.identity().translate(-s1x, -s1y), - # Normalize vector1 - _affine_vec2vec(s1_vec1, s2_vec1), - # Move to s2 start - Affine2D.identity().translate(s2x, s2y) - ] - affine = Affine2D.compose_ltr(transforms) - - # TODO if that doesn't fix vec1 we can give up - # TODO just testing vec2 would tell us if we should try mirroring - if _try_affine(affine, s1, s2): - return _round(affine, s1, s2) + s1_to_origin = Affine2D.identity().translate(-s1x, -s1y) + s2_to_origin = Affine2D.identity().translate(-s2x, -s2y) + s1_vec1_to_s2_vec2 = _affine_vec2vec(s1_vec1, s2_vec1) - # Last chance, try to mirror - transforms = ( - # Normalize vector 1 - transforms[:-1] - + [ - # Rotate first edge to lie on y axis - Affine2D.identity().rotate(-_angle(s2_vec1)), - Affine2D.flip_y(), - # Rotate back into position - Affine2D.identity().rotate(_angle(s2_vec1)), - ] - # Move to s2's start point - + transforms[-1:]) - - affine = Affine2D.compose_ltr(transforms) + # Move to s2 start + origin_to_s2 = Affine2D.identity().translate(s2x, s2y) + + affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec2, origin_to_s2)) if _try_affine(affine, s1, s2): return _round(affine, s1, s2) + # Could be non-uniform scaling or mirroring + # Try to match up the first y movement + + # Could be non-uniform scaling and/or mirroring + # Scale first y movement (after matching up vec1) to match + + # Rotate first edge to lie on x axis + s2_vec1_angle = _angle(s2_vec1) + rotate_s2vec1_onto_x = Affine2D.identity().rotate(-s2_vec1_angle) + rotate_s2vec1_off_x = Affine2D.identity().rotate(s2_vec1_angle) + + affine = Affine2D.compose_ltr( + (s1_to_origin, s1_vec1_to_s2_vec2, rotate_s2vec1_onto_x) + ) + s1_prime = _apply_affine(affine, s1) + + affine = Affine2D.compose_ltr((s2_to_origin, rotate_s2vec1_onto_x)) + s2_prime = _apply_affine(affine, s2) + + s1_vecy = _first_y(_vectors(s1_prime)) + s2_vecy = _first_y(_vectors(s2_prime)) + + if s1_vecy and s2_vecy: + affine = Affine2D.compose_ltr( + ( + s1_to_origin, + s1_vec1_to_s2_vec2, + # lie vec1 along x axis + rotate_s2vec1_onto_x, + # scale first y-vectors to match; x-parts should already match + Affine2D.identity().scale(1.0, s2_vecy.y / s1_vecy.y), + # restore the rotation we removed + rotate_s2vec1_off_x, + # drop into final position + origin_to_s2, + ) + ) + if _try_affine(affine, s1, s2): + return _round(affine, s1, s2) + # If we still aren't the same give up return None diff --git a/src/picosvg/svg_transform.py b/src/picosvg/svg_transform.py index 741fff1..18a1b89 100644 --- a/src/picosvg/svg_transform.py +++ b/src/picosvg/svg_transform.py @@ -20,7 +20,7 @@ from functools import reduce from math import cos, sin, radians, tan import re -from typing import Iterable, NamedTuple, Tuple +from typing import NamedTuple, Sequence, Tuple from sys import float_info from picosvg.geometric_types import Point, Rect, Vector @@ -151,21 +151,21 @@ def map_vector(self, vec: Tuple[float, float]) -> Vector: return Vector(self.a * x + self.c * y, self.b * x + self.d * y) @classmethod - def compose_ltr(cls, affines: Iterable["Affine2D"]) -> "Affine2D": + def compose_ltr(cls, affines: Sequence["Affine2D"]) -> "Affine2D": """Creates merged transform equivalent to applying transforms left-to-right order. Affines apply like functions - f(g(x)) - so we merge them in reverse order. """ - return reduce(lambda acc, a: cls.product(a, acc), reversed(affines), cls.identity()) - + return reduce( + lambda acc, a: cls.product(a, acc), reversed(affines), cls.identity() + ) def round(self, digits: int) -> "Affine2D": return Affine2D(*(round(v, digits) for v in self)) - @classmethod def rect_to_rect(cls, src: Rect, dst: Rect) -> "Affine2D": - """ Return Affine2D set to scale and translate src Rect to dst Rect. + """Return Affine2D set to scale and translate src Rect to dst Rect. The mapping completely fills dst, it does not preserve aspect ratio. """ if src.empty(): diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py index a62e8db..330d7dd 100644 --- a/src/picosvg/svg_types.py +++ b/src/picosvg/svg_types.py @@ -14,6 +14,7 @@ import copy import dataclasses +from itertools import zip_longest from picosvg.geometric_types import Point, Rect from picosvg.svg_meta import ( check_cmd, @@ -58,7 +59,7 @@ def _rewrite_coords(cmd_converter, coord_converter, curr_pos, cmd, args): desired_cmd = cmd_converter(cmd) if cmd != desired_cmd: cmd = desired_cmd - #if x_coord_idxs or y_coord_idxs: + # if x_coord_idxs or y_coord_idxs: args = list(args) # we'd like to mutate 'em for x_coord_idx in x_coord_idxs: args[x_coord_idx] += coord_converter(curr_pos.x) @@ -263,8 +264,24 @@ def round_floats(self, ndigits: int, inplace=False) -> "SVGShape": return target def almost_equals(self, other: "SVGShape", tolerance: int) -> bool: - assert isinstance(other, SVGShape) - return self.round_floats(tolerance) == other.round_floats(tolerance) + tol = 10 ** -tolerance + # print("almost_equals") + # print(" tol", tol) + # print(" self", self) + # print(" other", other) + # print(" self", self.as_path()) + # print(" other", other.as_path()) + # print(next(zip_longest(self.as_path(), other.as_path(), fillvalue=(None, ())))) + for (l_cmd, l_args), (r_cmd, r_args) in zip_longest( + self.as_path(), other.as_path(), fillvalue=(None, ()) + ): + if l_cmd != r_cmd or len(l_args) != len(r_args): + # print(f"cmd mismatch {l_cmd} != {r_cmd}") + return False + if any(abs(lv - rv) > tol for lv, rv in zip(l_args, r_args)): + # print(f"arg mismatch {l_cmd} {l_args}!= {r_args}") + return False + return True # https://www.w3.org/TR/SVG11/paths.html#PathElement diff --git a/tests/svg_reuse_test.py b/tests/svg_reuse_test.py index b876aea..914495e 100644 --- a/tests/svg_reuse_test.py +++ b/tests/svg_reuse_test.py @@ -56,68 +56,28 @@ ), # Triangles facing one another, same size ( - SVGPath( - d="m60,64 -50,-32 0,30 z" - ), - SVGPath( - d="m68,64 50,-32 0,30 z" - ), + SVGPath(d="m60,64 -50,-32 0,30 z"), + SVGPath(d="m68,64 50,-32 0,30 z"), Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), ), # Triangles, different rotation, different size ( - SVGPath( - d="m50,100 -48,-75 81,0 z" - ), - SVGPath( - d="m70,64 50,-32 0,54 z" - ), + SVGPath(d="m50,100 -48,-75 81,0 z"), + SVGPath(d="m70,64 50,-32 0,54 z"), Affine2D(a=-0.0, b=0.6667, c=-0.6667, d=-0.0, e=136.6667, f=30.6667), ), - # Tears from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f602 - ( - SVGPath( - d="M100.69,57.09c0,0,17,0.47,23.79,13c2.84,5.26,3,14-2.88,17.19c-7.11,3.91-13.78-1.64-14.63-7.56C104.68,63.66,100.69,57.09,100.69,57.09z" - ), - SVGPath( - d="M27.31,57.09c0,0-17,0.47-23.79,13C0.68,75.3,0.57,84,6.4,87.23c7.11,3.91,13.78-1.64,14.63-7.56C23.32,63.66,27.31,57.09,27.31,57.09z" - ), - Affine2D.identity(), - ), - # Top heart, bottom heart from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f970 - # This path is exported with 2 decimal places - ( - SVGPath( - d="M111.15,39.48c-2.24-0.61-22.59-6.5-25.8-18.44c-0.67-2.49-0.28-5.21,1.06-7.48c1.34-2.27,3.44-3.85,5.92-4.46c0.89-0.24,1.77-0.35,2.64-0.35c2.63,0,5.1,1.02,6.95,2.88l2.42,2.44l0.92-3.31c0.93-3.33,3.67-6.04,6.98-6.9c0.86-0.23,1.73-0.34,2.6-0.34c1.72,0,3.42,0.46,4.92,1.32c2.28,1.31,3.9,3.41,4.56,5.92C127.53,22.69,112.88,37.76,111.15,39.48z" - ), - SVGPath( - d="M88.42,121.72c-1.15-2.01-11.26-20.36-5.15-30.95c1.71-2.96,4.93-4.8,8.42-4.8c1.71,0,3.37,0.46,4.81,1.33c3.07,1.78,4.95,5.02,4.93,8.47l-0.01,3.44l3-1.69c1.45-0.82,3.12-1.25,4.82-1.25c1.73,0,3.42,0.45,4.87,1.31c2.27,1.33,3.89,3.43,4.57,5.93c0.68,2.5,0.34,5.1-0.95,7.32c-6.1,10.59-26.76,10.89-29.1,10.89L88.42,121.72z" - ), - Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), - ), - # Top heart, mid/left heart from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f970 - # This path is exported with 2 decimal places + # TODO triangles, one point stretched not aligned with X or Y + # A square and a rect; different scale for each axis ( - SVGPath( - d="M111.15,39.48c-2.24-0.61-22.59-6.5-25.8-18.44c-0.67-2.49-0.28-5.21,1.06-7.48c1.34-2.27,3.44-3.85,5.92-4.46c0.89-0.24,1.77-0.35,2.64-0.35c2.63,0,5.1,1.02,6.95,2.88l2.42,2.44l0.92-3.31c0.93-3.33,3.67-6.04,6.98-6.9c0.86-0.23,1.73-0.34,2.6-0.34c1.72,0,3.42,0.46,4.92,1.32c2.28,1.31,3.9,3.41,4.56,5.92C127.53,22.69,112.88,37.76,111.15,39.48z" - ), - SVGPath( - d="M33.95,97.7c-2.33-0.16-23.23-1.86-28.7-12.83c-1.14-2.28-1.3-5-0.45-7.47c0.85-2.47,2.58-4.42,4.87-5.5c1.43-0.71,2.91-1.06,4.43-1.06c1.92,0,3.78,0.56,5.37,1.63l2.86,1.91l0.24-3.43c0.24-3.41,2.37-6.58,5.41-8.07c1.4-0.69,2.87-1.04,4.39-1.04c1.05,0,2.09,0.17,3.1,0.5c2.47,0.82,4.45,2.54,5.59,4.84C46.54,78.14,35.31,95.65,33.95,97.7z" - ), - Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), + SVGRect(x=10, y=10, width=50, height=50), + SVGRect(x=70, y=20, width=20, height=100), + Affine2D(a=0.4, b=0.0, c=0.0, d=2.0, e=66.0, f=0.0), ), - # Top heart, bottom heart from https://rsheeter.github.io/android_fonts/emoji.html?q=u:1f970 - # This path is custom exported at high precision, at time of writing Noto svgs have 2 decimal places - # This is an interesting example because one of the hearts has extra path segments - # so a comparison of same # of very similar segments will fail. + # Squares with same first edge but flipped on Y ( - SVGPath( - d="M111.149414,39.480957c-2.240234-0.61377-22.592773-6.500488-25.797852-18.4375c-0.667969-2.485352-0.282227-5.212402,1.058594-7.481934c1.339844-2.268066,3.442383-3.852539,5.920898-4.461914c0.893555-0.237793,1.769531-0.353516,2.640625-0.353516c2.633789,0,5.101562,1.024414,6.949219,2.884277l2.423828,2.439453l0.921875-3.312988c0.926758-3.329102,3.665039-6.037109,6.976562-6.898438c0.859375-0.226562,1.731445-0.340332,2.59668-0.340332c1.716797,0,3.416992,0.455078,4.916016,1.316406c2.277344,1.305664,3.896484,3.40625,4.560547,5.915527C127.529297,22.690918,112.875,37.760254,111.149414,39.480957z" - ), - SVGPath( - d="M88.421875,121.71875c-1.148438-2.014648-11.257812-20.357422-5.149414-30.950195c1.707031-2.960938,4.931641-4.800781,8.415039-4.800781c1.709961,0,3.373047,0.459961,4.811523,1.330078c3.067383,1.77832,4.945312,5.017578,4.933594,8.47168l-0.011719,3.439453l2.995117-1.691406c1.449219-0.818359,3.117188-1.250977,4.824219-1.250977c1.733398,0,3.417969,0.452148,4.873047,1.305664c2.271484,1.325195,3.892578,3.431641,4.569336,5.933594c0.679688,2.504883,0.342773,5.103516-0.946289,7.321289c-6.098633,10.587891-26.760742,10.893555-29.09668,10.893555L88.421875,121.71875z" - ), - Affine2D(-1.0, 0.0, 0.0, 1.0, 128.0, -0.0), + SVGPath(d="M10,10 10,60 60,60 60,10 z"), + SVGPath(d="M70,120 90,120 90,20 70,20 z"), + Affine2D(a=0.0, b=-2.0, c=0.4, d=0.0, e=66.0, f=140.0), ), ], ) @@ -130,7 +90,11 @@ def test_svg_reuse(s1, s2, expected_affine): affine = affine_between(s1, s2) if expected_affine: - assert affine, f"No affine found between {s1} and {s2}. Expected {expected_affine}" + assert ( + affine + ), f"No affine found between {s1.d} and {s2.d}. Expected {expected_affine}" # Round because we've seen issues with different test environments when overly fine affine = affine.round(4) - assert affine == expected_affine + assert ( + affine == expected_affine + ), f"Unexpected affine found between {s1.d} and {s2.d}." From 4edf7ea0878f0feac7ade71093f7913dc23b6165 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Fri, 6 Nov 2020 10:31:52 -0800 Subject: [PATCH 05/15] Outdated comment --- src/picosvg/svg_reuse.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index 3a5b734..4f756d1 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -172,7 +172,8 @@ def _affine_callback(affine, subpath_start, curr_pos, cmd, args, *_unused): def normalize2(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape: - """Build a version of shape that will compare == to other shapes even if offset. + """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.""" From a5daafece867798cf480344824f3c703876a5f5c Mon Sep 17 00:00:00 2001 From: rsheeter Date: Fri, 6 Nov 2020 10:32:26 -0800 Subject: [PATCH 06/15] Update src/picosvg/svg_reuse.py Co-authored-by: Cosimo Lupo --- src/picosvg/svg_reuse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index 4f756d1..ca405a9 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -251,7 +251,7 @@ def _round(affine, s1, s2): s1_to_origin = Affine2D.identity().translate(-s1x, -s1y) s2_to_origin = Affine2D.identity().translate(-s2x, -s2y) - s1_vec1_to_s2_vec2 = _affine_vec2vec(s1_vec1, s2_vec1) + s1_vec1_to_s2_vec1 = _affine_vec2vec(s1_vec1, s2_vec1) # Move to s2 start origin_to_s2 = Affine2D.identity().translate(s2x, s2y) From 41c13f0685eac9a9cf544f2bdc8b11d858e2d2b5 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Fri, 6 Nov 2020 10:33:12 -0800 Subject: [PATCH 07/15] Fix name --- src/picosvg/svg_reuse.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index ca405a9..78f5b91 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -256,7 +256,7 @@ def _round(affine, s1, s2): # Move to s2 start origin_to_s2 = Affine2D.identity().translate(s2x, s2y) - affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec2, origin_to_s2)) + affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec1, origin_to_s2)) if _try_affine(affine, s1, s2): return _round(affine, s1, s2) @@ -272,7 +272,7 @@ def _round(affine, s1, s2): rotate_s2vec1_off_x = Affine2D.identity().rotate(s2_vec1_angle) affine = Affine2D.compose_ltr( - (s1_to_origin, s1_vec1_to_s2_vec2, rotate_s2vec1_onto_x) + (s1_to_origin, s1_vec1_to_s2_vec1, rotate_s2vec1_onto_x) ) s1_prime = _apply_affine(affine, s1) @@ -286,7 +286,7 @@ def _round(affine, s1, s2): affine = Affine2D.compose_ltr( ( s1_to_origin, - s1_vec1_to_s2_vec2, + s1_vec1_to_s2_vec1, # lie vec1 along x axis rotate_s2vec1_onto_x, # scale first y-vectors to match; x-parts should already match From 9a6e35ce4c3d8bc566a5244f761f7f0f1260989d Mon Sep 17 00:00:00 2001 From: rsheeter Date: Fri, 6 Nov 2020 10:44:42 -0800 Subject: [PATCH 08/15] Ran black --- src/picosvg/svg_reuse.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index 78f5b91..2200d0e 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -256,13 +256,10 @@ def _round(affine, s1, s2): # Move to s2 start origin_to_s2 = Affine2D.identity().translate(s2x, s2y) - affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec1, origin_to_s2)) + affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec1, origin_to_s2)) if _try_affine(affine, s1, s2): return _round(affine, s1, s2) - # Could be non-uniform scaling or mirroring - # Try to match up the first y movement - # Could be non-uniform scaling and/or mirroring # Scale first y movement (after matching up vec1) to match @@ -272,7 +269,7 @@ def _round(affine, s1, s2): rotate_s2vec1_off_x = Affine2D.identity().rotate(s2_vec1_angle) affine = Affine2D.compose_ltr( - (s1_to_origin, s1_vec1_to_s2_vec1, rotate_s2vec1_onto_x) + (s1_to_origin, s1_vec1_to_s2_vec1, rotate_s2vec1_onto_x) ) s1_prime = _apply_affine(affine, s1) @@ -286,7 +283,7 @@ def _round(affine, s1, s2): affine = Affine2D.compose_ltr( ( s1_to_origin, - s1_vec1_to_s2_vec1, + s1_vec1_to_s2_vec1, # lie vec1 along x axis rotate_s2vec1_onto_x, # scale first y-vectors to match; x-parts should already match From 2f6253fcfe8266832a38f1ed886b1ffe32f6251e Mon Sep 17 00:00:00 2001 From: rsheeter Date: Sun, 8 Nov 2020 21:42:41 -0800 Subject: [PATCH 09/15] Correct svg_meta ref left around --- src/picosvg/svg_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py index 330d7dd..d37a80e 100644 --- a/src/picosvg/svg_types.py +++ b/src/picosvg/svg_types.py @@ -55,7 +55,7 @@ def _explicit_lines_callback(subpath_start, curr_pos, cmd, args, *_): def _rewrite_coords(cmd_converter, coord_converter, curr_pos, cmd, args): - x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) + x_coord_idxs, y_coord_idxs = cmd_coords(cmd) desired_cmd = cmd_converter(cmd) if cmd != desired_cmd: cmd = desired_cmd From 2fa10b2fa95072363e3903bde2671bed883e4ba9 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Mon, 9 Nov 2020 09:22:31 -0800 Subject: [PATCH 10/15] Review feedback --- src/picosvg/svg.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/picosvg/svg.py b/src/picosvg/svg.py index 52a5bb1..80f3254 100644 --- a/src/picosvg/svg.py +++ b/src/picosvg/svg.py @@ -765,9 +765,10 @@ def _apply_gradient_translation(self, inplace=False): x_prime = (r1 - c * y_prime) / a # sanity check: a`(x`, y`) should be a(x, y) - p = affine.map_point((x, y)) + # all our float brutality damages points; low tolerence sanity checks! + p = Point(r1, r2) p_prime = affine_prime.map_point((x_prime, y_prime)) - assert p.almost_equals(p_prime) + assert p.almost_equals(p_prime, tolerence=1e-1), f"{p} != {p_prime}" el.attrib[x_attr] = ntos(round(x_prime, _GRADIENT_TRANSFORM_NDIGITS)) el.attrib[y_attr] = ntos(round(y_prime, _GRADIENT_TRANSFORM_NDIGITS)) From a96dacdee060f0ecb555ba220b3f238044713a5e Mon Sep 17 00:00:00 2001 From: rsheeter Date: Mon, 9 Nov 2020 11:35:45 -0800 Subject: [PATCH 11/15] Add objectBBox test --- tests/svg_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/svg_test.py b/tests/svg_test.py index 2b781b5..81294a0 100644 --- a/tests/svg_test.py +++ b/tests/svg_test.py @@ -431,6 +431,11 @@ def test_apply_style_attributes(actual, expected_result): '', '', ), + # Manually constructed objectBBox + ( + '', + '', + ), ], ) def test_apply_gradient_translation(gradient_string, expected_result): From 00d4fdcb4f6e500fdf784db99945d4cf18af5636 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Mon, 9 Nov 2020 20:20:09 -0800 Subject: [PATCH 12/15] tolerance cleanup --- src/picosvg/svg_reuse.py | 123 +++++++++++---------------------------- src/picosvg/svg_types.py | 17 ++---- tests/svg_reuse_test.py | 8 ++- 3 files changed, 43 insertions(+), 105 deletions(-) diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index 2200d0e..a45748c 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -25,11 +25,7 @@ from picosvg.svg_transform import Affine2D -# Number of decimal digits to round floats when normalizing or comparing -# TODO: hard to get #s to line up well with high tolerance -# TODO: maybe the input svgs should have higher precision? - only 2 decimals on hearts -_DEFAULT_TOLERANCE = 6 -_DEFAULT_LEVEL = 2 +_SIGNIFICANCE_FACTOR = 5 # Must be at least N x tolerance to be significant _ROUND_RANGE = range(3, 13) # range of rounds to try @@ -40,58 +36,6 @@ def _first_move(path: SVGPath) -> Tuple[float, float]: return args -def normalize( - shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE, level: int = _DEFAULT_LEVEL -) -> SVGShape: - return globals()[f"normalize{level}"](shape, tolerance) - - -def affine_between( - s1: SVGShape, - s2: SVGShape, - tolerance: int = _DEFAULT_TOLERANCE, - level: int = _DEFAULT_LEVEL, -) -> Optional[Affine2D]: - return globals()[f"affine_between{level}"](s1, s2, tolerance) - - -def normalize1(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape: - """Build a version of shape that will compare == to other shapes even if offset. - Intended use is to normalize multiple shapes to identify opportunity for reuse.""" - shape = dataclasses.replace(shape, id="") - path = shape.as_path() - x, y = _first_move(path) - return path.move(-x, -y, inplace=True).round_floats(tolerance, inplace=True) - - -def affine_between1( - s1: SVGShape, s2: SVGShape, tolerance: int = _DEFAULT_TOLERANCE -) -> Optional[Affine2D]: - """Returns the Affine2D to change s1 into s2 or None if no solution was found. - Implementation starting *very* basic, can improve over time. - """ - s1 = dataclasses.replace(s1, id="") - s2 = dataclasses.replace(s2, id="") - - if s1.almost_equals(s2, tolerance): - return Affine2D.identity() - - s1 = s1.as_path() - s2 = s2.as_path() - - s1x, s1y = _first_move(s1) - s2x, s2y = _first_move(s2) - dx = s2x - s1x - dy = s2y - s1y - - s1.move(dx, dy, inplace=True) - - if s1.almost_equals(s2, tolerance): - return Affine2D.identity().translate(dx, dy) - - return None - - def _vectors(path: SVGPath) -> Generator[Vector, None, None]: for cmd, args in path: x_coord_idxs, y_coord_idxs = svg_meta.cmd_coords(cmd) @@ -128,9 +72,10 @@ def _affine_vec2vec(initial: Vector, target: Vector) -> Affine2D: return affine -def _first_y(vectors: Iterable[Vector]) -> Optional[Vector]: +def _first_y(vectors: Iterable[Vector], tolerance: float) -> Optional[Vector]: + tolerance = _SIGNIFICANCE_FACTOR * tolerance for idx, vec in enumerate(vectors): - if idx > 0 and abs(vec.y) > 0.1: + if idx > 0 and abs(vec.y) > tolerance: return vec return None @@ -171,7 +116,7 @@ def _affine_callback(affine, subpath_start, curr_pos, cmd, args, *_unused): return ((cmd, args),) -def normalize2(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape: +def normalize(shape: SVGShape, tolerance: float, ndigits: int) -> SVGShape: """Build a version of shape that will compare == to other shapes even if offset, scaled, rotated, etc. @@ -189,7 +134,7 @@ def normalize2(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape path.walk(lambda *args: _affine_callback(affine1, *args)) # Scale first y movement to 1.0 - vecy = _first_y(_vectors(path)) + vecy = _first_y(_vectors(path), tolerance) if vecy and not almost_equal(vecy.y, 1.0): affine2 = Affine2D.identity().scale(1, 1 / vecy.y) path.walk(lambda *args: _affine_callback(affine2, *args)) @@ -198,36 +143,36 @@ def normalize2(shape: SVGShape, tolerance: int = _DEFAULT_TOLERANCE) -> SVGShape # TODO: what if shapes are the same but different drawing cmds # This DOES happen in Noto; extent unclear - path.round_floats(tolerance, inplace=True) + path.round_floats(ndigits, inplace=True) return path -def affine_between2( - s1: SVGShape, s2: SVGShape, tolerance: int = _DEFAULT_TOLERANCE -) -> Optional[Affine2D]: - """Returns the Affine2D to change s1 into s2 or None if no solution was found. +def _apply_affine(affine: Affine2D, s: SVGPath) -> SVGPath: + s_prime = copy.deepcopy(s) + s_prime.walk(lambda *args: _affine_callback(affine, *args)) + return s_prime - 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 - """ +def _try_affine(affine: Affine2D, s1: SVGPath, s2: SVGPath, tolerance: float): + return _apply_affine(affine, s1).almost_equals(s2, tolerance) - def _apply_affine(affine, s): - s_prime = copy.deepcopy(s) - s_prime.walk(lambda *args: _affine_callback(affine, *args)) - return s_prime - def _try_affine(affine, s1, s2): - return _apply_affine(affine, s1).almost_equals(s2, tolerance) +def _round(affine, s1, s2, tolerance): + # TODO bsearch? + for i in _ROUND_RANGE: + rounded = affine.round(i) + if _try_affine(rounded, s1, s2, tolerance): + return rounded + return affine # give up - def _round(affine, s1, s2): - # TODO bsearch? - for i in _ROUND_RANGE: - rounded = affine.round(i) - if _try_affine(rounded, s1, s2): - return rounded - return affine # give up +def affine_between(s1: SVGShape, s2: SVGShape, tolerance: float) -> Optional[Affine2D]: + """Returns the Affine2D to change 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 + + """ s1 = dataclasses.replace(s1, id="") s2 = dataclasses.replace(s2, id="") @@ -241,7 +186,7 @@ def _round(affine, s1, s2): s2x, s2y = _first_move(s2) affine = Affine2D.identity().translate(s2x - s1x, s2y - s1y) - if _try_affine(affine, s1, s2): + if _try_affine(affine, s1, s2, tolerance): return affine # Normalize first edge. @@ -257,8 +202,8 @@ def _round(affine, s1, s2): origin_to_s2 = Affine2D.identity().translate(s2x, s2y) affine = Affine2D.compose_ltr((s1_to_origin, s1_vec1_to_s2_vec1, origin_to_s2)) - if _try_affine(affine, s1, s2): - return _round(affine, s1, s2) + if _try_affine(affine, s1, s2, tolerance): + return _round(affine, s1, s2, tolerance) # Could be non-uniform scaling and/or mirroring # Scale first y movement (after matching up vec1) to match @@ -276,8 +221,8 @@ def _round(affine, s1, s2): affine = Affine2D.compose_ltr((s2_to_origin, rotate_s2vec1_onto_x)) s2_prime = _apply_affine(affine, s2) - s1_vecy = _first_y(_vectors(s1_prime)) - s2_vecy = _first_y(_vectors(s2_prime)) + s1_vecy = _first_y(_vectors(s1_prime), tolerance) + s2_vecy = _first_y(_vectors(s2_prime), tolerance) if s1_vecy and s2_vecy: affine = Affine2D.compose_ltr( @@ -294,8 +239,8 @@ def _round(affine, s1, s2): origin_to_s2, ) ) - if _try_affine(affine, s1, s2): - return _round(affine, s1, s2) + if _try_affine(affine, s1, s2, tolerance): + return _round(affine, s1, s2, tolerance) # If we still aren't the same give up return None diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py index d37a80e..4791b02 100644 --- a/src/picosvg/svg_types.py +++ b/src/picosvg/svg_types.py @@ -263,23 +263,13 @@ def round_floats(self, ndigits: int, inplace=False) -> "SVGShape": setattr(target, field.name, round(field_value, ndigits)) return target - def almost_equals(self, other: "SVGShape", tolerance: int) -> bool: - tol = 10 ** -tolerance - # print("almost_equals") - # print(" tol", tol) - # print(" self", self) - # print(" other", other) - # print(" self", self.as_path()) - # print(" other", other.as_path()) - # print(next(zip_longest(self.as_path(), other.as_path(), fillvalue=(None, ())))) + def almost_equals(self, other: "SVGShape", tolerance: float) -> bool: for (l_cmd, l_args), (r_cmd, r_args) in zip_longest( self.as_path(), other.as_path(), fillvalue=(None, ()) ): if l_cmd != r_cmd or len(l_args) != len(r_args): - # print(f"cmd mismatch {l_cmd} != {r_cmd}") return False - if any(abs(lv - rv) > tol for lv, rv in zip(l_args, r_args)): - # print(f"arg mismatch {l_cmd} {l_args}!= {r_args}") + if any(abs(lv - rv) > tolerance for lv, rv in zip(l_args, r_args)): return False return True @@ -358,7 +348,7 @@ def remove_overlaps(self, inplace=False) -> "SVGPath": def __iter__(self): return parse_svg_path(self.d, exploded=True) - def walk(self, callback): + def walk(self, callback) -> "SVGPath": """Walk path and call callback to build potentially new commands. https://www.w3.org/TR/SVG11/paths.html @@ -396,6 +386,7 @@ def callback(subpath_start, curr_xy, cmd, args, prev_xy, prev_cmd, prev_args) self.d = "" for _, cmd, args in new_cmds: self._add_cmd(cmd, *args) + return self def move(self, dx, dy, inplace=False): """Returns a new path that is this one shifted.""" diff --git a/tests/svg_reuse_test.py b/tests/svg_reuse_test.py index 914495e..cde4345 100644 --- a/tests/svg_reuse_test.py +++ b/tests/svg_reuse_test.py @@ -82,13 +82,15 @@ ], ) def test_svg_reuse(s1, s2, expected_affine): + ndigits = 3 + tolerance = 0.01 # if we can get an affine we should normalize to same shape if expected_affine: - assert normalize(s1) == normalize(s2) + assert normalize(s1, tolerance, ndigits) == normalize(s2, tolerance, ndigits) else: - assert normalize(s1) != normalize(s2) + assert normalize(s1, tolerance, ndigits) != normalize(s2, tolerance, ndigits) - affine = affine_between(s1, s2) + affine = affine_between(s1, s2, tolerance) if expected_affine: assert ( affine From f85f3c702b3076d3a4049cacf4c88a552f0e4fb1 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 10 Nov 2020 15:02:59 +0000 Subject: [PATCH 13/15] fix 'tolerence' typo --- src/picosvg/geometric_types.py | 12 ++++++------ src/picosvg/svg.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/picosvg/geometric_types.py b/src/picosvg/geometric_types.py index c2c9c50..3d99e5e 100644 --- a/src/picosvg/geometric_types.py +++ b/src/picosvg/geometric_types.py @@ -16,12 +16,12 @@ from typing import NamedTuple, Optional, Union -_DEFAULT_ALMOST_EQUAL_TOLERENCE = 1e-9 +_DEFAULT_ALMOST_EQUAL_TOLERANCE = 1e-9 _PointOrVec = Union["Point", "Vector"] -def almost_equal(c1, c2, tolerence=_DEFAULT_ALMOST_EQUAL_TOLERENCE) -> bool: - return abs(c1 - c2) <= tolerence +def almost_equal(c1, c2, tolerance=_DEFAULT_ALMOST_EQUAL_TOLERANCE) -> bool: + return abs(c1 - c2) <= tolerance class Point(NamedTuple): @@ -56,10 +56,10 @@ def round(self, digits: int) -> "Point": return Point(round(self.x, digits), round(self.y, digits)) def almost_equals( - self, other: "Point", tolerence=_DEFAULT_ALMOST_EQUAL_TOLERENCE + self, other: "Point", tolerance=_DEFAULT_ALMOST_EQUAL_TOLERANCE ) -> bool: - return almost_equal(self.x, other.x, tolerence) and almost_equal( - self.y, other.y, tolerence + return almost_equal(self.x, other.x, tolerance) and almost_equal( + self.y, other.y, tolerance ) diff --git a/src/picosvg/svg.py b/src/picosvg/svg.py index 80f3254..b82838a 100644 --- a/src/picosvg/svg.py +++ b/src/picosvg/svg.py @@ -765,10 +765,10 @@ def _apply_gradient_translation(self, inplace=False): x_prime = (r1 - c * y_prime) / a # sanity check: a`(x`, y`) should be a(x, y) - # all our float brutality damages points; low tolerence sanity checks! + # all our float brutality damages points; low tolerance sanity checks! p = Point(r1, r2) p_prime = affine_prime.map_point((x_prime, y_prime)) - assert p.almost_equals(p_prime, tolerence=1e-1), f"{p} != {p_prime}" + assert p.almost_equals(p_prime, tolerance=1e-1), f"{p} != {p_prime}" el.attrib[x_attr] = ntos(round(x_prime, _GRADIENT_TRANSFORM_NDIGITS)) el.attrib[y_attr] = ntos(round(y_prime, _GRADIENT_TRANSFORM_NDIGITS)) From 6408f78421b392562ab5af82a217c7247f705ee2 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Tue, 10 Nov 2020 11:12:04 -0800 Subject: [PATCH 14/15] Don't pass ndigits to normalize, just snap to nearest multiple of tolerence. --- src/picosvg/svg_reuse.py | 4 ++-- src/picosvg/svg_types.py | 29 +++++++++++++++++++++++++++++ tests/svg_reuse_test.py | 5 ++--- tests/svg_types_test.py | 14 ++++++++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/picosvg/svg_reuse.py b/src/picosvg/svg_reuse.py index a45748c..34798f3 100644 --- a/src/picosvg/svg_reuse.py +++ b/src/picosvg/svg_reuse.py @@ -116,7 +116,7 @@ def _affine_callback(affine, subpath_start, curr_pos, cmd, args, *_unused): return ((cmd, args),) -def normalize(shape: SVGShape, tolerance: float, ndigits: int) -> SVGShape: +def normalize(shape: SVGShape, tolerance: float) -> SVGShape: """Build a version of shape that will compare == to other shapes even if offset, scaled, rotated, etc. @@ -143,7 +143,7 @@ def normalize(shape: SVGShape, tolerance: float, ndigits: int) -> SVGShape: # TODO: what if shapes are the same but different drawing cmds # This DOES happen in Noto; extent unclear - path.round_floats(ndigits, inplace=True) + path.round_multiple(tolerance, inplace=True) return path diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py index 4791b02..15ca652 100644 --- a/src/picosvg/svg_types.py +++ b/src/picosvg/svg_types.py @@ -33,6 +33,10 @@ from typing import Generator, Iterable +def _round_multiple(f: float, of: float) -> float: + return round(f / of) * of + + def _explicit_lines_callback(subpath_start, curr_pos, cmd, args, *_): del subpath_start if cmd == "v": @@ -263,6 +267,17 @@ def round_floats(self, ndigits: int, inplace=False) -> "SVGShape": setattr(target, field.name, round(field_value, ndigits)) return target + def round_multiple(self, multiple_of: float, inplace=False) -> "SVGShape": + """Round all floats in SVGShape to nearest multiple of multiple_of.""" + target = self + if not inplace: + target = copy.deepcopy(self) + for field in dataclasses.fields(target): + field_value = getattr(self, field.name) + if isinstance(field_value, float): + setattr(target, field.name, _round_multiple(field_value, multiple_of)) + return target + def almost_equals(self, other: "SVGShape", tolerance: float) -> bool: for (l_cmd, l_args), (r_cmd, r_args) in zip_longest( self.as_path(), other.as_path(), fillvalue=(None, ()) @@ -552,6 +567,20 @@ def round_floats(self, ndigits: int, inplace=False) -> "SVGPath": return target + def round_multiple(self, multiple_of: float, inplace=False) -> "SVGPath": + """Round all floats in SVGPath to given decimal digits. + + Also reformat the SVGPath.d string floats with the same rounding. + """ + target: SVGPath = super().round_multiple(multiple_of, inplace=inplace).as_path() + + d, target.d = target.d, "" + for cmd, args in parse_svg_path(d): + target._add_cmd(cmd, *(_round_multiple(n, multiple_of) for n in args)) + + return target + + # https://www.w3.org/TR/SVG11/shapes.html#CircleElement @dataclasses.dataclass class SVGCircle(SVGShape): diff --git a/tests/svg_reuse_test.py b/tests/svg_reuse_test.py index cde4345..c0bd821 100644 --- a/tests/svg_reuse_test.py +++ b/tests/svg_reuse_test.py @@ -82,13 +82,12 @@ ], ) def test_svg_reuse(s1, s2, expected_affine): - ndigits = 3 tolerance = 0.01 # if we can get an affine we should normalize to same shape if expected_affine: - assert normalize(s1, tolerance, ndigits) == normalize(s2, tolerance, ndigits) + assert normalize(s1, tolerance) == normalize(s2, tolerance) else: - assert normalize(s1, tolerance, ndigits) != normalize(s2, tolerance, ndigits) + assert normalize(s1, tolerance) != normalize(s2, tolerance) affine = affine_between(s1, s2, tolerance) if expected_affine: diff --git a/tests/svg_types_test.py b/tests/svg_types_test.py index d25f2e7..ca5c4c2 100644 --- a/tests/svg_types_test.py +++ b/tests/svg_types_test.py @@ -241,3 +241,17 @@ def test_apply_style_attribute(shape, expected): actual = shape.apply_style_attribute() assert actual == expected assert shape.apply_style_attribute(inplace=True) == expected + +@pytest.mark.parametrize( + "path, multiple_of, expected_result", + [ + ("m1,1 2,0 1,3", 0.1, "m1,1 2,0 1,3"), + # why a multiple that divides evenly into 1 is a good idea + ("m1,1 2,0 1,3", 0.128, "m1.024,1.024 2.048,0 1.024,2.944"), + ], +) +def test_round_multiple(path: str, multiple_of: float, expected_result: str): + actual = SVGPath(d=path).round_multiple(multiple_of, inplace=True).d + print(f"A: {actual}") + print(f"E: {expected_result}") + assert actual == expected_result From 331183ecebe0abff04fda00bf0332f1a2a6d9385 Mon Sep 17 00:00:00 2001 From: rsheeter Date: Tue, 10 Nov 2020 11:13:07 -0800 Subject: [PATCH 15/15] black --- src/picosvg/svg_types.py | 3 +-- tests/svg_types_test.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/picosvg/svg_types.py b/src/picosvg/svg_types.py index 15ca652..f9158a0 100644 --- a/src/picosvg/svg_types.py +++ b/src/picosvg/svg_types.py @@ -34,7 +34,7 @@ def _round_multiple(f: float, of: float) -> float: - return round(f / of) * of + return round(f / of) * of def _explicit_lines_callback(subpath_start, curr_pos, cmd, args, *_): @@ -566,7 +566,6 @@ def round_floats(self, ndigits: int, inplace=False) -> "SVGPath": return target - def round_multiple(self, multiple_of: float, inplace=False) -> "SVGPath": """Round all floats in SVGPath to given decimal digits. diff --git a/tests/svg_types_test.py b/tests/svg_types_test.py index ca5c4c2..b7a711f 100644 --- a/tests/svg_types_test.py +++ b/tests/svg_types_test.py @@ -242,6 +242,7 @@ def test_apply_style_attribute(shape, expected): assert actual == expected assert shape.apply_style_attribute(inplace=True) == expected + @pytest.mark.parametrize( "path, multiple_of, expected_result", [