diff --git a/android/src/toga_android/widgets/canvas.py b/android/src/toga_android/widgets/canvas.py index b30c8261ca..4c5c343caa 100644 --- a/android/src/toga_android/widgets/canvas.py +++ b/android/src/toga_android/widgets/canvas.py @@ -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, diff --git a/changes/4158.feature.md b/changes/4158.feature.md new file mode 100644 index 0000000000..4722f33cf8 --- /dev/null +++ b/changes/4158.feature.md @@ -0,0 +1 @@ +Canvas widgets now support drawing arcs with tangent lines and rounded rectangles. diff --git a/cocoa/src/toga_cocoa/libs/core_graphics.py b/cocoa/src/toga_cocoa/libs/core_graphics.py index f082238103..a5922a7fa3 100644 --- a/cocoa/src/toga_cocoa/libs/core_graphics.py +++ b/cocoa/src/toga_cocoa/libs/core_graphics.py @@ -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, diff --git a/cocoa/src/toga_cocoa/widgets/canvas.py b/cocoa/src/toga_cocoa/widgets/canvas.py index de809ba345..fe7d041a79 100644 --- a/cocoa/src/toga_cocoa/widgets/canvas.py +++ b/cocoa/src/toga_cocoa/widgets/canvas.py @@ -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, diff --git a/core/src/toga/widgets/canvas/__init__.py b/core/src/toga/widgets/canvas/__init__.py index 4d606afaf4..d078dc07b3 100644 --- a/core/src/toga/widgets/canvas/__init__.py +++ b/core/src/toga/widgets/canvas/__init__.py @@ -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 @@ -75,4 +75,5 @@ def __getattr__(name): # Geometry "arc_to_bezier", "sweepangle", + "arc_to_quad_points", ] diff --git a/core/src/toga/widgets/canvas/drawingaction.py b/core/src/toga/widgets/canvas/drawingaction.py index 3875138f3a..ab69eba2bf 100644 --- a/core/src/toga/widgets/canvas/drawingaction.py +++ b/core/src/toga/widgets/canvas/drawingaction.py @@ -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 diff --git a/core/src/toga/widgets/canvas/geometry.py b/core/src/toga/widgets/canvas/geometry.py index f3a0a7b95a..49bd48057f 100644 --- a/core/src/toga/widgets/canvas/geometry.py +++ b/core/src/toga/widgets/canvas/geometry.py @@ -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: @@ -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 @@ -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], diff --git a/core/src/toga/widgets/canvas/state.py b/core/src/toga/widgets/canvas/state.py index cc4a84905b..b4d3e92f07 100644 --- a/core/src/toga/widgets/canvas/state.py +++ b/core/src/toga/widgets/canvas/state.py @@ -15,6 +15,7 @@ from .drawingaction import ( Arc, + ArcTo, BeginPath, BezierCurveTo, ClosePath, @@ -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 @@ -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, diff --git a/core/tests/widgets/canvas/test_draw_operations.py b/core/tests/widgets/canvas/test_draw_operations.py index 967ab2f5ad..c4ed75e9a2 100644 --- a/core/tests/widgets/canvas/test_draw_operations.py +++ b/core/tests/widgets/canvas/test_draw_operations.py @@ -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", [ diff --git a/core/tests/widgets/canvas/test_helpers.py b/core/tests/widgets/canvas/test_helpers.py index 6149134ecc..5d9735e916 100644 --- a/core/tests/widgets/canvas/test_helpers.py +++ b/core/tests/widgets/canvas/test_helpers.py @@ -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(): @@ -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)]) diff --git a/dummy/src/toga_dummy/widgets/canvas.py b/dummy/src/toga_dummy/widgets/canvas.py index c5c167df1f..e1b707b0e5 100644 --- a/dummy/src/toga_dummy/widgets/canvas.py +++ b/dummy/src/toga_dummy/widgets/canvas.py @@ -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, diff --git a/gtk/src/toga_gtk/widgets/canvas.py b/gtk/src/toga_gtk/widgets/canvas.py index 892514ad70..cace25ce9c 100644 --- a/gtk/src/toga_gtk/widgets/canvas.py +++ b/gtk/src/toga_gtk/widgets/canvas.py @@ -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, @@ -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, diff --git a/iOS/src/toga_iOS/libs/core_graphics.py b/iOS/src/toga_iOS/libs/core_graphics.py index 5d9849fbbf..c2c763b776 100644 --- a/iOS/src/toga_iOS/libs/core_graphics.py +++ b/iOS/src/toga_iOS/libs/core_graphics.py @@ -87,6 +87,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, diff --git a/iOS/src/toga_iOS/widgets/canvas.py b/iOS/src/toga_iOS/widgets/canvas.py index 353a37a14c..59574f8ffa 100644 --- a/iOS/src/toga_iOS/widgets/canvas.py +++ b/iOS/src/toga_iOS/widgets/canvas.py @@ -159,6 +159,10 @@ def arc( 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, diff --git a/qt/src/toga_qt/widgets/canvas.py b/qt/src/toga_qt/widgets/canvas.py index ad14f97f7f..b195cd8765 100644 --- a/qt/src/toga_qt/widgets/canvas.py +++ b/qt/src/toga_qt/widgets/canvas.py @@ -16,7 +16,7 @@ from toga.colors import rgb from toga.constants import Baseline, FillRule -from toga.widgets.canvas.geometry import arc_to_bezier, sweepangle +from toga.widgets.canvas.geometry import arc_to_bezier, arc_to_quad_points, sweepangle from ..colors import native_color from .base import Widget @@ -128,6 +128,28 @@ def arc(self, x, y, radius, startangle, endangle, counterclockwise): -degrees(sweepangle(startangle, endangle, counterclockwise)), ) + def arc_to(self, x1, y1, x2, y2, radius): + if self._path.elementCount() == 0: + # if this is the first point of the path, move to + self._path.moveTo(x1, y1) + + current_point = self._path.currentPosition() + x0 = current_point.x() + y0 = current_point.y() + + # 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._path.lineTo(*points[0]) + + if len(points) == 5: + cp1, t2, cp2, t3 = points[1:] + + # use 2 quad Bezier curve as approximation to circular arc + self._path.quadTo(*cp1, *t2) + self._path.quadTo(*cp2, *t3) + def ellipse( self, x, diff --git a/testbed/src/testbed/resources/canvas/arc_to.png b/testbed/src/testbed/resources/canvas/arc_to.png new file mode 100644 index 0000000000..06c48d2d6b Binary files /dev/null and b/testbed/src/testbed/resources/canvas/arc_to.png differ diff --git a/testbed/tests/widgets/test_canvas.py b/testbed/tests/widgets/test_canvas.py index 351b8ae924..0a42d7934f 100644 --- a/testbed/tests/widgets/test_canvas.py +++ b/testbed/tests/widgets/test_canvas.py @@ -24,7 +24,7 @@ from toga.images import Image as TogaImage from toga.style.pack import SYSTEM, Pack -from .conftest import build_cleanup_test +from .conftest import build_cleanup_test, skip_on_backends, xfail_on_backends from .properties import ( # noqa: F401 test_background_color, test_background_color_reset, @@ -417,6 +417,30 @@ async def test_arc(canvas, probe): assert_reference(probe, "arc", threshold=0.03) +async def test_arc_to(canvas, probe): + "An arc given by tangents and radius can be drawn" + skip_on_backends("toga_android", reason="Can't compute current point for tangent.") + canvas.root_state.begin_path() + + canvas.root_state.move_to(115, 10) + canvas.root_state.arc_to(120, 10, 120, 15, 30) + canvas.root_state.arc_to(120, 120, 25, 25, 10) + canvas.root_state.stroke() + + # If there is no current point the HTML spec says to make (x1, y1) + # the current point (effectively draws straight line from (x1, y1) + # to (x2, y2)). Firefox does not conform to the spec. + # https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-arcto + canvas.root_state.begin_path() + canvas.root_state.arc_to(180, 180, 100, 180, 30) + canvas.root_state.line_to(50, 180) + canvas.root_state.stroke() + + await probe.redraw("Arcs and lines should be drawn") + xfail_on_backends("toga_winforms", reason="Draws a too-long line.") + assert_reference(probe, "arc_to") + + async def test_ellipse(canvas, probe): "An ellipse can be drawn" diff --git a/winforms/src/toga_winforms/widgets/canvas.py b/winforms/src/toga_winforms/widgets/canvas.py index ac6f0cf9c9..dd37b83398 100644 --- a/winforms/src/toga_winforms/widgets/canvas.py +++ b/winforms/src/toga_winforms/widgets/canvas.py @@ -25,7 +25,7 @@ from toga.colors import TRANSPARENT, rgb from toga.constants import Baseline, FillRule from toga.handlers import WeakrefCallable -from toga.widgets.canvas import arc_to_bezier, sweepangle +from toga.widgets.canvas import arc_to_bezier, arc_to_quad_points, sweepangle from toga_winforms.colors import native_color from .box import Box @@ -101,7 +101,8 @@ def get_last_point(self, default_x, default_y): elif self.start_point: return self.start_point else: - return PointF(default_x, default_y) + self.start_point = PointF(default_x, default_y) + return self.start_point def transform_path(self, matrix): """Transform the current path using a matrix.""" @@ -193,6 +194,22 @@ 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): + last_point = self.get_last_point(x1, y1) + x0, y0 = (last_point.X, last_point.Y) + + # 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.line_to(*points[0]) + + if len(points) == 5: + # use 2 quad Bezier curve as approximation to circular arc + cp1, t2, cp2, t3 = points[1:] + self.quadratic_curve_to(*cp1, *t2) + self.quadratic_curve_to(*cp2, *t3) + def ellipse( self, x,