Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Winforms and Android Canvas fixes #2184

Merged
merged 5 commits into from
Nov 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -14,7 +15,7 @@
from org.beeware.android import DrawHandlerView, IDrawHandler
from travertino.size import at_least

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 @@ -101,25 +102,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 @@ -136,19 +120,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 @@ -1628,3 +1628,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