Skip to content

Commit

Permalink
Arc skews
Browse files Browse the repository at this point in the history
  • Loading branch information
regebro committed May 5, 2023
1 parent a351ed5 commit c8e4a18
Show file tree
Hide file tree
Showing 2 changed files with 155 additions and 26 deletions.
88 changes: 68 additions & 20 deletions src/svg/path/path.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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]


Expand All @@ -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]
Expand Down Expand Up @@ -739,10 +737,60 @@ def boundingbox(self):
return [x_min, y_min, x_max, y_max]

def transform(self, matrix):
# This ARCane (hurhur) 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)

# Base assumption is no transformation:
new_rotation = self.rotation
new_radius = self.radius

# 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
new_a = sqrt(1 / lambda1)
new_b = sqrt(1 / lambda2)
new_radius = complex(new_a, new_b)
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
new_a = sqrt(1 / lambda2)
new_b = sqrt(1 / lambda1)
new_radius = complex(new_a, new_b)
new_rotation = degrees(atan((A - C + D) / B))

return self.__class__(
_xform(self.start, matrix),
self.radius,
self.rotation,
new_radius,
new_rotation,
self.arc,
self.sweep,
_xform(self.end, matrix),
Expand Down
93 changes: 87 additions & 6 deletions tests/test_transforms.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,97 @@
import unittest

from svg import path
from svg.transform import make_matrix
from svg import path, transform
from math import pi, tan


class TransformTests(unittest.TestCase):
def test_svg_path_transform(self):
def _confirm_skews(self, original):
for alpha in range(2, 5):
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)

for x in range(1, 101):
px = xformed_x.point(x * 0.01)
py = xformed_y.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

def test_confirm_line_skews(self):
line = path.Line(0, 100 + 100j)
self._confirm_skews(line)

def test_confirm_quad_skews(self):
quad = path.QuadraticBezier(0, 100j, 100 + 100j)
self._confirm_skews(quad)

def test_confirm_cube_skews(self):
cube = path.CubicBezier(0, +100j, 100, 100 + 100j)
self._confirm_skews(cube)

def test_confirm_arc_skews(self):
arc1 = path.Arc(-100, 100 + 50j, 0, 0, 1, 100)
self._confirm_skews(arc1)

arc2 = path.Arc(100, 100 + 50j, 0, 0, 1, -100)
self._confirm_skews(arc2)

def notest_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 90, 30 Q 90, 60 50, 90"
# )

# #arc = d
# for m in (#transform.rotate_matrix((-10*2*pi)/360),
# #transform.translate_matrix(-36, 45.5),
# transform.skewx_matrix((40*2*pi)/360),
# transform.scale_matrix(1, 0.5)):

# p = arc.point(0)
# t.goto(p.real - 1, -p.imag + 1)
# t.dot(3, 'black')
# t.pendown()
# for x in range(1, 101):
# p = arc.point(x * 0.01)
# t.goto(p.real - 1, -p.imag + 1)
# t.penup()
# t.dot(3, 'black')
# arc = arc.transform(m)

# import pdb;pdb.set_trace()

0 comments on commit c8e4a18

Please sign in to comment.