Skip to content

Commit

Permalink
Merge pull request #2184 from beeware/winforms-canvas
Browse files Browse the repository at this point in the history
Winforms and Android Canvas fixes
  • Loading branch information
freakboy3742 committed Nov 2, 2023
2 parents d661272 + ffa8d80 commit 055e6a3
Show file tree
Hide file tree
Showing 12 changed files with 450 additions and 115 deletions.
56 changes: 22 additions & 34 deletions android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from math import degrees, pi
import itertools
from math import degrees

from android.graphics import (
Bitmap,
Expand All @@ -13,7 +14,7 @@
from java.io import ByteArrayOutputStream
from org.beeware.android import DrawHandlerView, IDrawHandler

from toga.widgets.canvas import Baseline, FillRule
from toga.widgets.canvas import Baseline, FillRule, arc_to_bezier, sweepangle

from ..colors import native_color
from .base import Widget
Expand Down Expand Up @@ -100,25 +101,8 @@ def quadratic_curve_to(self, cpx, cpy, x, y, path, **kwargs):
path.quadTo(cpx, cpy, x, y)

def arc(self, x, y, radius, startangle, endangle, anticlockwise, path, **kwargs):
sweepangle = endangle - startangle
if anticlockwise:
if sweepangle > 0:
sweepangle -= 2 * pi
else:
if sweepangle < 0:
sweepangle += 2 * pi

# HTML says sweep angles should be clamped at +/- 360 degrees, but Android uses
# mod 360 instead, so 360 would cause the circle to completely disappear.
limit = 359.999 # Must be less than 360 in 32-bit floating point.
path.arcTo(
x - radius,
y - radius,
x + radius,
y + radius,
degrees(startangle),
max(-limit, min(degrees(sweepangle), limit)),
False, # forceMoveTo
self.ellipse(
x, y, radius, radius, 0, startangle, endangle, anticlockwise, path, **kwargs
)

def ellipse(
Expand All @@ -135,19 +119,23 @@ def ellipse(
**kwargs,
):
matrix = Matrix()
matrix.postScale(radiusx, radiusy)
matrix.postRotate(degrees(rotation))
matrix.postTranslate(x, y)

# Creating the ellipse as a separate path and then using addPath would make it a
# disconnected contour. And there's no way to extract the segments from a path
# until getPathIterator in API level 34. So this is the simplest solution I
# could find.
inverse = Matrix()
matrix.invert(inverse)
path.transform(inverse)
self.arc(0, 0, 1, startangle, endangle, anticlockwise, path)
path.transform(matrix)
matrix.preTranslate(x, y)
matrix.preRotate(degrees(rotation))
matrix.preScale(radiusx, radiusy)
matrix.preRotate(degrees(startangle))

coords = list(
itertools.chain(
*arc_to_bezier(sweepangle(startangle, endangle, anticlockwise))
)
)
matrix.mapPoints(coords)

self.line_to(coords[0], coords[1], path, **kwargs)
i = 2
while i < len(coords):
self.bezier_curve_to(*coords[i : i + 6], path, **kwargs)
i += 6

def rect(self, x, y, width, height, path, **kwargs):
path.addRect(x, y, x + width, y + height, Path.Direction.CW)
Expand Down
1 change: 1 addition & 0 deletions changes/2184.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed issues with elliptical arcs on WinForms and Android
77 changes: 76 additions & 1 deletion core/src/toga/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import warnings
from abc import ABC, abstractmethod
from contextlib import contextmanager
from math import pi
from math import cos, pi, sin, tan
from typing import Protocol

from travertino.colors import Color
Expand Down Expand Up @@ -1631,3 +1631,78 @@ def stroke(
DeprecationWarning,
)
return self.Stroke(color=color, line_width=line_width, line_dash=line_dash)


def sweepangle(startangle, endangle, anticlockwise):
"""Returns an arc length in the range [-2 * pi, 2 * pi], where positive numbers are
clockwise. Based on the "ellipse method steps" in the HTML spec."""

if anticlockwise:
if endangle - startangle <= -2 * pi:
return -2 * pi
else:
if endangle - startangle >= 2 * pi:
return 2 * pi

startangle %= 2 * pi
endangle %= 2 * pi
sweepangle = endangle - startangle
if anticlockwise:
if sweepangle > 0:
sweepangle -= 2 * pi
else:
if sweepangle < 0:
sweepangle += 2 * pi

return sweepangle


# Based on https://stackoverflow.com/a/30279817
def arc_to_bezier(sweepangle):
"""Approximates an arc of a unit circle as a sequence of Bezier segments.
:param sweepangle: Length of the arc in radians, where positive numbers are
clockwise.
:returns: [(1, 0), (cp1x, cp1y), (cp2x, cp2y), (x, y), ...], where each group of 3
points has the same meaning as in the bezier_curve_to method, and there are
between 1 and 4 groups."""

matrices = [
[1, 0, 0, 1], # 0 degrees
[0, -1, 1, 0], # 90
[-1, 0, 0, -1], # 180
[0, 1, -1, 0], # 270
]

if sweepangle < 0: # Anticlockwise
sweepangle *= -1
for matrix in matrices:
matrix[2] *= -1
matrix[3] *= -1

result = [(1.0, 0.0)]
for matrix in matrices:
if sweepangle < 0:
break

phi = min(sweepangle, pi / 2)
k = 4 / 3 * tan(phi / 4)
result += [
transform(x, y, matrix)
for x, y in [
(1, k),
(cos(phi) + k * sin(phi), sin(phi) - k * cos(phi)),
(cos(phi), sin(phi)),
]
]

sweepangle -= pi / 2

return result


def transform(x, y, matrix):
return (
x * matrix[0] + y * matrix[1],
x * matrix[2] + y * matrix[3],
)
218 changes: 218 additions & 0 deletions core/tests/widgets/canvas/test_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
from math import pi

from pytest import approx

from toga.widgets.canvas import arc_to_bezier, sweepangle


def test_sweepangle():
# Zero start angles
for value in [0, 1, pi, 2 * pi]:
assert sweepangle(0, value, False) == approx(value)

for value in [2.1 * pi, 3 * pi, 4 * pi, 5 * pi]:
assert sweepangle(0, value, False) == approx(2 * pi)

# Non-zero start angles
assert sweepangle(pi, 2 * pi, False) == approx(pi)
assert sweepangle(pi, 2.5 * pi, False) == approx(1.5 * pi)
assert sweepangle(pi, 3 * pi, False) == approx(2 * pi)
assert sweepangle(pi, 3.1 * pi, False) == approx(2 * pi)

# Zero crossings
assert sweepangle(0, 2 * pi, False) == approx(2 * pi)
assert sweepangle(0, -2 * pi, False) == approx(0)
assert sweepangle(0, 1.9 * pi, False) == approx(1.9 * pi)
assert sweepangle(0, 2.1 * pi, False) == approx(2 * pi)
assert sweepangle(0, -1.9 * pi, False) == approx(0.1 * pi)
assert sweepangle(0, -2.1 * pi, False) == approx(1.9 * pi)
assert sweepangle(pi, 0, False) == approx(pi)
assert sweepangle(pi, 2 * pi, False) == approx(pi)
assert sweepangle(pi, 0.1 * pi, False) == approx(1.1 * pi)
assert sweepangle(pi, 2.1 * pi, False) == approx(1.1 * pi)

# Zero crossings, anticlockwise
assert sweepangle(0, 2 * pi, True) == approx(0)
assert sweepangle(0, -2 * pi, True) == approx(-2 * pi)
assert sweepangle(0, 1.9 * pi, True) == approx(-0.1 * pi)
assert sweepangle(0, 2.1 * pi, True) == approx(-1.9 * pi)
assert sweepangle(0, -1.9 * pi, True) == approx(-1.9 * pi)
assert sweepangle(0, -2.1 * pi, True) == approx(-2 * pi)
assert sweepangle(pi, 0, True) == approx(-pi)
assert sweepangle(pi, 2 * pi, True) == approx(-pi)
assert sweepangle(pi, 0.1 * pi, True) == approx(-0.9 * pi)
assert sweepangle(pi, 2.1 * pi, True) == approx(-0.9 * pi)


def assert_arc_to_bezier(sweepangle, expected):
actual = arc_to_bezier(sweepangle)
for a, e in zip(actual, expected):
assert a == approx(e, abs=0.000001)


def test_arc_to_bezier():
assert_arc_to_bezier(
0,
[
(1.0, 0.0),
(1.0, 0.0),
(1.0, 0.0),
(1.0, 0.0),
],
)

assert_arc_to_bezier(
0.25 * pi,
[
(1.0, 0.0),
(1.0, 0.2652164),
(0.8946431, 0.5195704),
(0.7071067, 0.7071067),
],
)
assert_arc_to_bezier(
-0.25 * pi,
[
(1.0, 0.0),
(1.0, -0.2652164),
(0.8946431, -0.5195704),
(0.7071067, -0.7071067),
],
)

assert_arc_to_bezier(
0.5 * pi,
[
(1.0, 0.0),
(1.0, 0.5522847),
(0.5522847, 1.0),
(0.0, 1.0),
],
)
assert_arc_to_bezier(
-0.5 * pi,
[
(1.0, 0.0),
(1.0, -0.5522847),
(0.5522847, -1.0),
(0.0, -1.0),
],
)

assert_arc_to_bezier(
0.75 * pi,
[
(1.0, 0.0),
(1.0, 0.5522847),
(0.5522847, 1.0),
(0.0, 1.0),
(-0.2652164, 1.0),
(-0.5195704, 0.8946431),
(-0.7071067, 0.7071067),
],
)
assert_arc_to_bezier(
-0.75 * pi,
[
(1.0, 0.0),
(1.0, -0.5522847),
(0.5522847, -1.0),
(0.0, -1.0),
(-0.2652164, -1.0),
(-0.5195704, -0.8946431),
(-0.7071067, -0.7071067),
],
)

assert_arc_to_bezier(
1 * pi,
[
(1.0, 0.0),
(1.0, 0.5522847),
(0.5522847, 1.0),
(0.0, 1.0),
(-0.5522847, 1.0),
(-1.0, 0.5522847),
(-1.0, 0.0),
],
)
assert_arc_to_bezier(
-1 * pi,
[
(1.0, 0.0),
(1.0, -0.5522847),
(0.5522847, -1.0),
(0.0, -1.0),
(-0.5522847, -1.0),
(-1.0, -0.5522847),
(-1.0, 0.0),
],
)

assert_arc_to_bezier(
1.5 * pi,
[
(1.0, 0.0),
(1.0, 0.5522847),
(0.5522847, 1.0),
(0.0, 1.0),
(-0.5522847, 1.0),
(-1.0, 0.5522847),
(-1.0, 0.0),
(-1.0, -0.5522847),
(-0.5522847, -1.0),
(0.0, -1.0),
],
)
assert_arc_to_bezier(
-1.5 * pi,
[
(1.0, 0.0),
(1.0, -0.5522847),
(0.5522847, -1.0),
(0.0, -1.0),
(-0.5522847, -1.0),
(-1.0, -0.5522847),
(-1.0, 0.0),
(-1.0, 0.5522847),
(-0.5522847, 1.0),
(0.0, 1.0),
],
)

assert_arc_to_bezier(
2 * pi,
[
(1.0, 0.0),
(1.0, 0.5522847),
(0.5522847, 1.0),
(0.0, 1.0),
(-0.5522847, 1.0),
(-1.0, 0.5522847),
(-1.0, 0.0),
(-1.0, -0.5522847),
(-0.5522847, -1.0),
(0.0, -1.0),
(0.5522847, -1.0),
(1.0, -0.5522847),
(1.0, 0.0),
],
)
assert_arc_to_bezier(
-2 * pi,
[
(1.0, 0.0),
(1.0, -0.5522847),
(0.5522847, -1.0),
(0.0, -1.0),
(-0.5522847, -1.0),
(-1.0, -0.5522847),
(-1.0, 0.0),
(-1.0, 0.5522847),
(-0.5522847, 1.0),
(0.0, 1.0),
(0.5522847, 1.0),
(1.0, 0.5522847),
(1.0, 0.0),
],
)
Loading

0 comments on commit 055e6a3

Please sign in to comment.