Skip to content
Draft
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
3 changes: 3 additions & 0 deletions android/src/toga_android/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ def quadratic_curve_to(self, cpx, cpy, x, y):
def arc(self, x, y, radius, startangle, endangle, counterclockwise):
self.ellipse(x, y, radius, radius, 0, startangle, endangle, counterclockwise)

def arc_to(self, x1, y1, x2, y2, radius):
raise NotImplementedError("Android canvas doesn't implement arc_to().")

def ellipse(
self,
x,
Expand Down
1 change: 1 addition & 0 deletions changes/4158.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Canvas widgets now support drawing arcs with tangent lines and rounded rectangles.
9 changes: 9 additions & 0 deletions cocoa/src/toga_cocoa/libs/core_graphics.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ class CGAffineTransform(Structure):
CGFloat,
c_int,
]
core_graphics.CGContextAddArcToPoint.restype = c_void_p
core_graphics.CGContextAddArcToPoint.argtypes = [
CGContextRef,
CGFloat,
CGFloat,
CGFloat,
CGFloat,
CGFloat,
]
core_graphics.CGContextAddCurveToPoint.restype = c_void_p
core_graphics.CGContextAddCurveToPoint.argtypes = [
CGContextRef,
Expand Down
4 changes: 4 additions & 0 deletions cocoa/src/toga_cocoa/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ def arc(self, x, y, radius, startangle, endangle, counterclockwise):
self.native, x, y, radius, startangle, endangle, clockwise
)

def arc_to(self, x1, y1, x2, y2, radius):
self._ensure_subpath(x1, y1)
core_graphics.CGContextAddArcToPoint(self.native, x1, y1, x2, y2, radius)

def ellipse(
self,
x,
Expand Down
3 changes: 2 additions & 1 deletion core/src/toga/widgets/canvas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
Translate,
WriteText,
)
from .geometry import arc_to_bezier, sweepangle
from .geometry import arc_to_bezier, arc_to_quad_points, sweepangle
from .state import ClosedPathContext, FillContext, State, StrokeContext

# Make sure deprecation warnings are shown by default
Expand Down Expand Up @@ -75,4 +75,5 @@ def __getattr__(name):
# Geometry
"arc_to_bezier",
"sweepangle",
"arc_to_quad_points",
]
18 changes: 18 additions & 0 deletions core/src/toga/widgets/canvas/drawingaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,24 @@ def _draw(self, context: Any) -> None:
)


@dataclass(repr=False)
class ArcTo(DrawingAction):
x1: float
y1: float
x2: float
y2: float
radius: float

def _draw(self, context: Any) -> None:
context.arc_to(
self.x1,
self.y1,
self.x2,
self.y2,
self.radius,
)


@dataclass(repr=False)
class Ellipse(DrawingAction):
x: float
Expand Down
81 changes: 79 additions & 2 deletions core/src/toga/widgets/canvas/geometry.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from math import cos, pi, sin, tan
from math import acos, cos, hypot, pi, sin, sqrt, tan
from typing import TypeAlias

point: TypeAlias = tuple[float, float]


def sweepangle(startangle: float, endangle: float, counterclockwise: bool) -> float:
Expand Down Expand Up @@ -26,7 +29,7 @@ def sweepangle(startangle: float, endangle: float, counterclockwise: bool) -> fl


# Based on https://stackoverflow.com/a/30279817
def arc_to_bezier(sweepangle: float) -> list[tuple[float, float]]:
def arc_to_bezier(sweepangle: float) -> list[point]:
"""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
Expand Down Expand Up @@ -69,6 +72,80 @@ def arc_to_bezier(sweepangle: float) -> list[tuple[float, float]]:
return result


def normalize_vector(x, y):
"""Given a vector, return its unit length representation."""
length = hypot(x, y)
return (x / length, y / length)


# Based on Kiva source code, BSD Licensed from Enthought
# https://github.com/enthought/enable/blob/5db2be21cce1198929011dc56a7edc7f8b0dcde5/kiva/arc_conversion.py#L41
# extended to generate curves for 2 quad bezier curves rather than 1
def arc_to_quad_points(
start: point,
p1: point,
p2: point,
radius: float,
) -> tuple[point, ...]:
"""Calculate the tangents and control points to turn arc_to() into cubic bezier.

Given a starting point, two endpoints of a line segment, and a radius,
calculate the control points that approximate arc_to() with two quadratic
Bezier curves.

:param start: The current point on the path.
:p1: The intersection point of the two tangent lines.
:p2: A point on the second tangent line.
:returns: points (t1, [cp1, t2, cp2, t3]), where t1, t2, and t3 are tangent points
on the circle, and cp1, cp2 are the control points for the quadratic Bezier
curves.
"""
if radius == 0 or start == p1 or p1 == p2:
return (p1,)

# calculate the angle between the two line segments
v1 = normalize_vector(start[0] - p1[0], start[1] - p1[1])
v2 = normalize_vector(p2[0] - p1[0], p2[1] - p1[1])
angle = acos(v1[0] * v2[0] + v1[1] * v2[1])

# punt if the half angle is zero or a multiple of pi
sin_half_angle = sin(angle / 2.0)
if abs(sin_half_angle) <= 0.00001:
# 180 turn
return (p1,)

# calculate the distance from p1 to the center of the arc
dist_to_center = radius / sin_half_angle
# calculate the distance from p1 to each tangent point
dist_to_tangent = sqrt(dist_to_center**2 - radius**2)

if abs(dist_to_tangent) <= 0.00001:
# straight line
return (p1,)

# calculate the tangent points
t1 = (p1[0] + v1[0] * dist_to_tangent, p1[1] + v1[1] * dist_to_tangent)
t3 = (p1[0] + v2[0] * dist_to_tangent, p1[1] + v2[1] * dist_to_tangent)

# control points live on the tangent lines and the segment between them should
# also be a tangent to the circle perpendicular to line from center to p1
# Can calculate with similar right triangles (start, c, p1) and (b, cp1, p1)
# where b is the point where the segment between the center and p1 intersects
# the circle.
distance_to_control = (dist_to_center - radius) / dist_to_tangent * dist_to_center
cp1 = (p1[0] + v1[0] * distance_to_control, p1[1] + v1[1] * distance_to_control)
cp2 = (p1[0] + v2[0] * distance_to_control, p1[1] + v2[1] * distance_to_control)

# calculate tangent point where line from p1 to center intersects the arc
# - normalized direction vector from p1 to center
v = normalize_vector(v1[0] + v2[0], v1[1] + v2[1])
# - distance along vector
distance_to_arc = dist_to_center - radius
t2 = (p1[0] + v[0] * distance_to_arc, p1[1] + v[1] * distance_to_arc)

return (t1, cp1, t2, cp2, t3)


def transform(x: float, y: float, matrix: list[int]) -> tuple[float, float]:
return (
x * matrix[0] + y * matrix[1],
Expand Down
30 changes: 30 additions & 0 deletions core/src/toga/widgets/canvas/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from .drawingaction import (
Arc,
ArcTo,
BeginPath,
BezierCurveTo,
ClosePath,
Expand Down Expand Up @@ -175,6 +176,7 @@ def arc(

:param x: The X coordinate of the circle's center.
:param y: The Y coordinate of the circle's center.
:param radius: The radius coordinate of the circle.
:param startangle: The start angle in radians, measured clockwise from the
positive X axis.
:param endangle: The end angle in radians, measured clockwise from the positive
Expand All @@ -189,6 +191,34 @@ def arc(
self._action_target.append(arc)
return arc

def arc_to(
self,
x1: float,
y1: float,
x2: float,
y2: float,
radius: float,
) -> ArcTo:
"""Draw a circular arc specified by tangents and a radius in the canvas state.

This draws a straight line and circular arc from the current point to the point
(x2, y2). The arc is drawn with the specified radius and tangent to the line
segments from the current point to (x1, y1) and the line segment from (x1, y1)
to (x2, y2). This effectively draws the arc in the "corner" formed by the two
tangent lines.

:param x1: The X coordinate of the 'corner' point.
:param y1: The Y coordinate of the 'corner' point.
:param x2: The X coordinate of the point determining the point.
:param y2: The Y coordinate of the 'end' point.
:param radius: The radius coordinate of the circular arc.
:returns: The `ArcTo` [`DrawingAction`][toga.widgets.canvas.DrawingAction]
for the operation.
"""
arc_to = ArcTo(x1, y1, x2, y2, radius)
self._action_target.append(arc_to)
return arc_to

def ellipse(
self,
x: float,
Expand Down
33 changes: 33 additions & 0 deletions core/tests/widgets/canvas/test_draw_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,39 @@ def test_arc(widget, kwargs, args_repr, draw_kwargs):
assert getattr(draw_op, attr) == value


@pytest.mark.parametrize(
"kwargs, args_repr, draw_kwargs",
[
(
{"x1": 10, "y1": 20, "x2": 30, "y2": 40, "radius": 50},
("x1=10, y1=20, x2=30, y2=40, radius=50"),
{
"x1": 10,
"y1": 20,
"x2": 30,
"y2": 40,
"radius": 50,
},
)
],
)
def test_arc_to(widget, kwargs, args_repr, draw_kwargs):
"""An arc_to operation can be added."""
draw_op = widget.root_state.arc_to(**kwargs)

assert_action_performed(widget, "redraw")
assert repr(draw_op) == f"ArcTo({args_repr})"

# The first and last instructions save/restore the root state, and can be ignored.
assert widget._impl.draw_instructions[1:-1] == [
("arc_to", draw_kwargs),
]

# All the attributes can be retrieved.
for attr, value in draw_kwargs.items():
assert getattr(draw_op, attr) == value


@pytest.mark.parametrize(
"kwargs, args_repr, draw_kwargs",
[
Expand Down
63 changes: 62 additions & 1 deletion core/tests/widgets/canvas/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from pytest import approx

from toga.widgets.canvas import arc_to_bezier, sweepangle
from toga.widgets.canvas import arc_to_bezier, arc_to_quad_points, sweepangle


def test_sweepangle():
Expand Down Expand Up @@ -216,3 +216,64 @@ def test_arc_to_bezier():
(1.0, 0.0),
],
)


def assert_arc_to_quad_points(args, expected):
actual = arc_to_quad_points(*args)
for a, e in zip(actual, expected, strict=True):
assert a[0] == approx(e[0], abs=0.000001)
assert a[1] == approx(e[1], abs=0.000001)


def test_arc_to_quad_points():
assert_arc_to_quad_points(
[(10, 10), (20, 10), (20, 20), 10],
[
(10, 10),
(14.1421356, 10),
(17.07106781, 12.92893218),
(20, 15.85786437),
(20, 20),
],
)
assert_arc_to_quad_points(
[(0, 10), (20, 10), (20, 30), 10],
[
(10, 10),
(14.1421356, 10),
(17.07106781, 12.92893218),
(20, 15.85786437),
(20, 20),
],
)
assert_arc_to_quad_points(
[(15, 10), (20, 10), (20, 15), 10],
[
(10, 10),
(14.1421356, 10),
(17.07106781, 12.92893218),
(20, 15.85786437),
(20, 20),
],
)

# The following handle the edge-cases in the standard:
# https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-arcto

# straight
assert_arc_to_quad_points([(10, 10), (20, 10), (30, 10), 10], [(20, 10)])
assert_arc_to_quad_points([(0, 10), (20, 10), (40, 10), 10], [(20, 10)])
assert_arc_to_quad_points([(15, 10), (20, 10), (25, 10), 10], [(20, 10)])

# 180 degrees
assert_arc_to_quad_points([(10, 10), (20, 10), (10, 10), 10], [(20, 10)])
assert_arc_to_quad_points([(15, 10), (20, 10), (15, 10), 10], [(20, 10)])
assert_arc_to_quad_points([(5, 10), (20, 10), (5, 10), 10], [(20, 10)])

# radius 0
assert_arc_to_quad_points([(10, 10), (20, 10), (20, 20), 0], [(20, 10)])

# identical points
assert_arc_to_quad_points([(20, 10), (20, 10), (20, 10), 10], [(20, 10)])
assert_arc_to_quad_points([(20, 10), (20, 10), (20, 20), 10], [(20, 10)])
assert_arc_to_quad_points([(10, 10), (20, 10), (20, 10), 10], [(20, 10)])
14 changes: 14 additions & 0 deletions dummy/src/toga_dummy/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@ def arc(self, x, y, radius, startangle, endangle, counterclockwise):
)
)

def arc_to(self, x1, y1, x2, y2, radius):
self.impl.draw_instructions.append(
(
"arc_to",
{
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
"radius": radius,
},
)
)

def ellipse(
self,
x,
Expand Down
20 changes: 20 additions & 0 deletions gtk/src/toga_gtk/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from toga.constants import Baseline, FillRule
from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE
from toga.handlers import WeakrefCallable
from toga.widgets.canvas.geometry import arc_to_quad_points
from toga_gtk.colors import native_color
from toga_gtk.libs import (
GTK_VERSION,
Expand Down Expand Up @@ -108,6 +109,25 @@ def arc(self, x, y, radius, startangle, endangle, counterclockwise):
else:
self.native.arc(x, y, radius, startangle, endangle)

def arc_to(self, x1, y1, x2, y2, radius):
if not self.native.has_current_point():
# if this is the first point of the path, move to
self.native.move_to(x1, y1)

x0, y0 = self.native.get_current_point()

# get tangent points and control points
points = arc_to_quad_points((x0, y0), (x1, y1), (x2, y2), radius)

# draw line to start of arc
self.native.line_to(*points[0])

if len(points) == 5:
cp1, t2, cp2, t3 = points[1:]
# use 2 quad Bezier curve as approximation to circular arc
self.quadratic_curve_to(*cp1, *t2)
self.quadratic_curve_to(*cp2, *t3)

def ellipse(
self,
x,
Expand Down
Loading