From a351ed57c5c7be24e880f231d54ae907c0f64371 Mon Sep 17 00:00:00 2001 From: Lennart Regebro Date: Mon, 1 May 2023 18:21:45 +0200 Subject: [PATCH 1/2] Add a transform() method to Path and segments --- setup.cfg | 1 + src/svg/path/path.py | 58 ++++++++++++++++++++++++++++++++++++++++ tests/test_transforms.py | 16 +++++++++++ 3 files changed, 75 insertions(+) create mode 100644 tests/test_transforms.py diff --git a/setup.cfg b/setup.cfg index 8971d1a..3b9a8f7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ where = src [options.extras_require] test = + svg.transform pytest pytest-cov Pillow diff --git a/src/svg/path/path.py b/src/svg/path/path.py index 318f6c1..cc551c0 100644 --- a/src/svg/path/path.py +++ b/src/svg/path/path.py @@ -1,6 +1,7 @@ from math import sqrt, cos, sin, acos, degrees, radians, log, pi from bisect import bisect from abc import ABC, abstractmethod +from array import array import math try: @@ -15,6 +16,11 @@ ERROR = 1e-12 +def _xform(coord, matrix): + res = matrix @ array("f", [coord.real, coord.imag, 1]) + return complex(res[0], res[1]) + + def _find_solutions_for_bezier(c2, c1, c0): """Find solutions of c2 * t^2 + c1 * t + c0 = 0 where t in [0, 1]""" soln = [] @@ -152,6 +158,13 @@ def length(self, error=None, min_depth=None): distance = self.end - self.start return sqrt(distance.real**2 + distance.imag**2) + def transform(self, matrix): + return self.__class__( + _xform(self.start, matrix), + _xform(self.end, matrix), + relative=self.relative, + ) + class Line(Linear): def __init__(self, start, end, relative=False, vertical=False, horizontal=False): @@ -200,6 +213,15 @@ def boundingbox(self): y_max = max(self.start.imag, self.end.imag) return [x_min, y_min, x_max, y_max] + def transform(self, matrix): + return self.__class__( + _xform(self.start, matrix), + _xform(self.end, matrix), + relative=self.relative, + vertical=self.vertical, + horizontal=self.horizontal, + ) + class CubicBezier(NonLinear): def __init__(self, start, control1, control2, end, relative=False, smooth=False): @@ -323,6 +345,16 @@ def boundingbox(self): y_min, y_max = min(y_coords), max(y_coords) return [x_min, y_min, x_max, y_max] + def transform(self, matrix): + return self.__class__( + _xform(self.start, matrix), + _xform(self.control1, matrix), + _xform(self.control2, matrix), + _xform(self.end, matrix), + relative=self.relative, + smooth=self.smooth, + ) + class QuadraticBezier(NonLinear): def __init__(self, start, control, end, relative=False, smooth=False): @@ -461,6 +493,15 @@ def boundingbox(self): y_min, y_max = min(y_coords), max(y_coords) return [x_min, y_min, x_max, y_max] + def transform(self, matrix): + return self.__class__( + _xform(self.start, matrix), + _xform(self.control, matrix), + _xform(self.end, matrix), + relative=self.relative, + smooth=self.smooth, + ) + class Arc(NonLinear): def __init__(self, start, radius, rotation, arc, sweep, end, relative=False): @@ -697,6 +738,17 @@ def boundingbox(self): y_min, y_max = min(y_coords), max(y_coords) return [x_min, y_min, x_max, y_max] + def transform(self, matrix): + return self.__class__( + _xform(self.start, matrix), + self.radius, + self.rotation, + self.arc, + self.sweep, + _xform(self.end, matrix), + self.relative, + ) + class Move: """Represents move commands. Does nothing, but is there to handle @@ -747,6 +799,9 @@ def boundingbox(self): y_max = max(self.start.imag, self.end.imag) return [x_min, y_min, x_max, y_max] + def transform(self, matrix): + return self.__class__(_xform(self.start, matrix), self.relative) + class Close(Linear): """Represents the closepath command""" @@ -898,3 +953,6 @@ def boundingbox(self): x_min, x_max = min(x_coords), max(x_coords) y_min, y_max = min(y_coords), max(y_coords) return [x_min, y_min, x_max, y_max] + + def transform(self, matrix): + return self.__class__(*[segment.transform(matrix) for segment in self]) diff --git a/tests/test_transforms.py b/tests/test_transforms.py new file mode 100644 index 0000000..411adad --- /dev/null +++ b/tests/test_transforms.py @@ -0,0 +1,16 @@ +import unittest + +from svg import path +from svg.transform import make_matrix + + +class TransformTests(unittest.TestCase): + def test_svg_path_transform(self): + line = path.Line(0, 100 + 100j) + xline = line.transform(make_matrix(sx=0.1, sy=0.2)) + assert xline == path.Line(0, 10 + 20j) + + d = path.parser.parse_path("M 750,100 L 250,900 L 1250,900 z") + # Makes it 10% as big in x and 20% as big in y + td = d.transform(make_matrix(sx=0.1, sy=0.2)) + assert td.d() == "M 75,20 L 25,180 L 125,180 z" From c02e2525df6d394ea041e1ec15c524b6228f5424 Mon Sep 17 00:00:00 2001 From: Lennart Regebro Date: Wed, 3 May 2023 20:43:18 +0200 Subject: [PATCH 2/2] Arc skews --- src/svg/path/path.py | 95 ++++++++++++++++----- tests/test_transforms.py | 178 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 248 insertions(+), 25 deletions(-) diff --git a/src/svg/path/path.py b/src/svg/path/path.py index cc551c0..fa75440 100644 --- a/src/svg/path/path.py +++ b/src/svg/path/path.py @@ -1,8 +1,7 @@ -from math import sqrt, cos, sin, acos, degrees, radians, log, pi +from math import sqrt, cos, sin, acos, atan, degrees, radians, log, pi, floor, ceil from bisect import bisect from abc import ABC, abstractmethod from array import array -import math try: from collections.abc import MutableSequence @@ -30,8 +29,8 @@ def _find_solutions_for_bezier(c2, c1, c0): else: det = c1**2 - 4 * c2 * c0 if det >= 0: - soln.append((-c1 + math.pow(det, 0.5)) / 2.0 / c2) - soln.append((-c1 - math.pow(det, 0.5)) / 2.0 / c2) + soln.append((-c1 + pow(det, 0.5)) / 2.0 / c2) + soln.append((-c1 - pow(det, 0.5)) / 2.0 / c2) return [s for s in soln if 0.0 <= s and s <= 1.0] @@ -42,34 +41,33 @@ def _find_solutions_for_arc(a, b, c, d): # pi / 2 + pi * n = c + d * t # --> n = d / pi * t - (1/2 - c/pi) # --> t = (pi / 2 - c + pi * n) / d - n_ranges = [-0.5 + c / math.pi, d / math.pi - 0.5 + c / math.pi] - n_range_start = math.floor(min(n_ranges)) - n_range_end = math.ceil(max(n_ranges)) + n_ranges = [-0.5 + c / pi, d / pi - 0.5 + c / pi] + n_range_start = floor(min(n_ranges)) + n_range_end = ceil(max(n_ranges)) t_list = [ - (math.pi / 2 - c + math.pi * n) / d - for n in range(n_range_start, n_range_end + 1) + (pi / 2 - c + pi * n) / d for n in range(n_range_start, n_range_end + 1) ] elif b == 0: # when n \in Z # pi * n = c + d * t # --> n = d / pi * t + c / pi # --> t = (- c + pi * n) / d - n_ranges = [c / math.pi, d / math.pi + c / math.pi] - n_range_start = math.floor(min(n_ranges)) - n_range_end = math.ceil(max(n_ranges)) - t_list = [(-c + math.pi * n) / d for n in range(n_range_start, n_range_end + 1)] + n_ranges = [c / pi, d / pi + c / pi] + n_range_start = floor(min(n_ranges)) + n_range_end = ceil(max(n_ranges)) + t_list = [(-c + pi * n) / d for n in range(n_range_start, n_range_end + 1)] else: # when n \in Z # arct = tan^-1 (- b / a) and # arct + pi * n = c + d * t # --> n = (c - arct + d * t) / pi # --> t = (arct - c + pi * n) / d - arct = math.atan(-b / a) - n_ranges = [(c - arct) / math.pi, d / math.pi + (c - arct) / math.pi] - n_range_start = math.floor(min(n_ranges)) - n_range_end = math.ceil(max(n_ranges)) + arct = atan(-b / a) + n_ranges = [(c - arct) / pi, d / pi + (c - arct) / pi] + n_range_start = floor(min(n_ranges)) + n_range_end = ceil(max(n_ranges)) t_list = [ - (arct - c + math.pi * n) / d for n in range(n_range_start, n_range_end + 1) + (arct - c + pi * n) / d for n in range(n_range_start, n_range_end + 1) ] t_list = [t for t in t_list if 0.0 <= t and t <= 1.0] @@ -739,10 +737,67 @@ def boundingbox(self): return [x_min, y_min, x_max, y_max] def transform(self, matrix): + # This arcane magic is adapted from + # https://math.stackexchange.com/questions/2068583/ + # + # A=1/a^2 + # B/2=−tanβ/a^2 + # C=1/b^2+tan2β/a^2 + # D=√((A+C)^2+B^2−4AC) (useful) + # λ1,2=(A+C∓D)/2 + # new a = √(1/λ1) + # new b = √(1/λ2) + # new rotation = atan((A-C+D)/B) + + new_rotation = self.rotation + rx = self.radius.real + ry = self.radius.imag + + # Now look for skews: + skewx_angle = matrix[0][1] + skewy_angle = matrix[1][0] + + if skewx_angle: + a = self.radius.real + b = self.radius.imag + tan_beta = -skewx_angle + A = 1 / (a**2) + B = -2 * tan_beta / a**2 + C = (1 / b**2) + tan_beta**2 / a**2 + D = sqrt((A + C) ** 2 + B**2 - 4 * A * C) + lambda1 = (A + C - D) / 2 + lambda2 = (A + C + D) / 2 + rx = sqrt(1 / lambda1) + ry = sqrt(1 / lambda2) + new_rotation += degrees(atan((A - C + D) / B)) + + if skewy_angle: + a = self.radius.imag + b = self.radius.real + tan_beta = skewy_angle + A = 1 / (a**2) + B = -2 * tan_beta / a**2 + C = (1 / b**2) + tan_beta**2 / a**2 + D = sqrt((A + C) ** 2 + B**2 - 4 * A * C) + lambda1 = (A + C - D) / 2 + lambda2 = (A + C + D) / 2 + rxb = sqrt(1 / lambda2) + ryb = sqrt(1 / lambda1) + if skewx_angle: + rx = rx + rxb + ry = ry + ryb + else: + rx = rxb + ry = ryb + new_rotation += degrees(atan((A - C + D) / B)) + + rx *= matrix[0][0] + ry *= matrix[1][1] + return self.__class__( _xform(self.start, matrix), - self.radius, - self.rotation, + complex(rx, ry), + new_rotation, self.arc, self.sweep, _xform(self.end, matrix), diff --git a/tests/test_transforms.py b/tests/test_transforms.py index 411adad..7bc58c3 100644 --- a/tests/test_transforms.py +++ b/tests/test_transforms.py @@ -1,16 +1,184 @@ import unittest -from svg import path -from svg.transform import make_matrix +from svg import path, transform +from math import pi, tan, atan, sqrt +from cmath import polar, rect + + +class BaseTransformTests: + def _confirm_transform(self, original): + pass + + def notest_confirm_lines(self): + for line in ( + path.Line(0, 100 + 100j), + path.Line(0, 100), + path.Line(0, 100j), + path.Line(-100 + 50j, -250+200j), + ): + self._confirm_transform(line) + + def notest_confirm_quads(self): + for quad in ( + path.QuadraticBezier(0, 100j, 100 + 100j), + path.QuadraticBezier(300+100j, 200+200j, 200+300j), + path.QuadraticBezier(6 + 2j, 5 - 1j, 6 + 2j), + ): + self._confirm_transform(quad) + + def notest_confirm_cubes(self): + for cube in( + path.CubicBezier(0, +100j, 100, 100 + 100j), + ): + self._confirm_transform(cube) + + def test_confirm_arcs(self): + for arc in ( + path.Arc(-200-200j, 400 + 400j, 0, 0, 1, 200+200j), + #path.Arc(-200, 200 + 200j, 0, 0, 1, 200), + #path.Arc(-100, 100 + 50j, 0, 0, 1, 100), + #path.Arc(100, 100 + 50j, 0, 0, 1, -100), + ): + self._confirm_transform(arc) + + +class TranslateTransformTests(BaseTransformTests, unittest.TestCase): + def _confirm_transform(self, original): + for x in (-100, -5, 0, 0.1, 1, 5, 10, 157): + for y in (-100, -5, 0, 0.1, 1, 5, 10, 157): + scale = transform.translate_matrix(x, y) + xformed = original.transform(scale) + + for pos in range(0, 101): + opoint = original.point(pos*0.01) + xpoint = xformed.point(pos*0.01) + assert abs(xpoint.real - opoint.real - x) < 0.00001 + assert abs(xpoint.imag - opoint.imag - y) < 0.00001 + + +class ScaleTransformTests(BaseTransformTests, unittest.TestCase): + def _confirm_transform(self, original): + for x in (0.1, 0.5, 1, 1.1, 1.5, 2, 10): + for y in (0.1, 0.5, 1, 1.1, 1.5, 2, 10): + scale = transform.scale_matrix(x, y) + xformed = original.transform(scale) + + for pos in range(0, 101): + opoint = original.point(pos*0.01) + xpoint = xformed.point(pos*0.01) + assert abs(xpoint.real - opoint.real * x) < 0.00001 + assert abs(xpoint.imag - opoint.imag * y) < 0.00001 + + +class SkewTransformTests(BaseTransformTests, unittest.TestCase): + def _confirm_transform(self, original): + for alpha in range(1, 2): + angle = (alpha * 15 * 2 * pi) / 360 + skewx = transform.skewx_matrix(angle) + xformed_x = original.transform(skewx) + skewy = transform.skewy_matrix(angle) + xformed_y = original.transform(skewy) + skewboth = transform.make_matrix(ax=angle, ay=angle) + xformed_both2 = original.transform(skewboth) + xformed_both = path.Arc(xformed_both2.start, 780+780j, 0, 0, 1, xformed_both2.end) + + from .utils import DebugTurtle + with DebugTurtle(steps=25) as t: + #t.draw_path(original) + #t.draw_path(original, color="blue", matrix=skewx) + #t.draw_path(xformed_x, color="light blue") + #t.draw_path(original, color="green", matrix=skewy) + #t.draw_path(xformed_y, color="light green") + t.draw_path(original, color="red", matrix=skewboth) + t.draw_path(xformed_both, color="pink") + + for x in range(1, 101): + px = xformed_x.point(x * 0.01) + py = xformed_y.point(x * 0.01) + pb = xformed_both.point(x * 0.01) + o = original.point(x * 0.01) + + assert abs(px.real - (tan(angle) * o.imag + o.real)) < 0.00001 + assert abs(px.imag - o.imag) < 0.00001 + + assert abs(py.real - o.real) < 0.00001 + assert abs(py.imag - (tan(angle) * o.real + o.imag)) < 0.00001 + + print(x, "X:", abs(pb.real - (tan(angle) * o.imag + o.real))) + print(x, "Y:", abs(pb.imag - (tan(angle) * o.real + o.imag))) + + assert 1==0 + + +class RotateTransformTests(BaseTransformTests, unittest.TestCase): + def _confirm_transform(self, original): + for alpha in range(1, 7): + angle = (alpha * 15 * 2 * pi) / 360 + skewx = transform.rotate_matrix(angle) + xformed = original.transform(skewx) + + for x in range(1, 101): + px = xformed.point(x * 0.01) + o = original.point(x * 0.01) + + c, phi = polar(o) + px2 = rect(c, phi+angle) + assert abs(px - px2) < 1e-10 class TransformTests(unittest.TestCase): + def test_svg_path_transform(self): line = path.Line(0, 100 + 100j) - xline = line.transform(make_matrix(sx=0.1, sy=0.2)) - assert xline == path.Line(0, 10 + 20j) + linex = line.transform(transform.make_matrix(sx=0.1, sy=0.2)) + assert linex == path.Line(0, 10 + 20j) d = path.parser.parse_path("M 750,100 L 250,900 L 1250,900 z") # Makes it 10% as big in x and 20% as big in y - td = d.transform(make_matrix(sx=0.1, sy=0.2)) + td = d.transform(transform.make_matrix(sx=0.1, sy=0.2)) assert td.d() == "M 75,20 L 25,180 L 125,180 z" + + d = path.parser.parse_path( + "M 10, 30 A 20, 20 0, 0, 1 50, 30 A 20,20 0, 0, 1 90, 30 Q 90, 60 50, 90 Q 10, 60 10, 30 z" + ) + # Makes it 10% as big in x and 20% as big in y + m = ( + transform.rotate_matrix((-10 * 2 * pi) / 360, 50, 100) + @ transform.translate_matrix(-36, 45.5) + @ transform.skewx_matrix((40 * 2 * pi) / 360) + @ transform.scale_matrix(1, 0.5) + ) + td = d.transform(m) + # assert ( + # td.d() + # == "M 149.32,82.6809 A 20,20 0 0,1 115.757,104.442 A 20,20 0 0,1 82.1939,126.203 " + # "Q 88.0949,104.5 127.559,61.0359 Q 155.221,60.978 149.32,82.6809 z" + # ) + + import turtle + t = turtle.Turtle() + t.penup() + arc = path.parser.parse_path( + "M 10, 30 A 20, 20 0, 0, 1 50, 30 A 20,20 0, 0, 1 90, 30 Q 90, 60 50, 90 Q 10, 60 10, 30 z" + ) + + arc = arc.transform(transform.scale_matrix(3) @ transform.translate_matrix(-50, -50)) + for m in ( + transform.skewx_matrix((40*2*pi)/360), + transform.scale_matrix(1, 0.5), + transform.translate_matrix(-36, 45.5), + #transform.rotate_matrix((-10*2*pi)/360), + #transform.scale_matrix(1, 0.5) + ): + p = arc.point(0) + t.goto(p.real, -p.imag) + t.dot(3, 'black') + t.pendown() + for x in range(1, 101): + p = arc.point(x * 0.01) + t.goto(p.real, -p.imag) + t.penup() + t.dot(3, 'black') + arc = arc.transform(m) + + import pdb;pdb.set_trace()