# /*##########################################################################
#
# Copyright (c) 2018-2024 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""This module provides Arc ROI item for the :class:`~silx.gui.plot.PlotWidget`.
"""

__authors__ = ["V. Valls"]
__license__ = "MIT"
__date__ = "28/06/2018"

import logging
import numpy
import enum
from typing import Tuple

from ... import utils
from .. import items
from ...colors import rgba
from ....utils.proxy import docstring
from ._roi_base import HandleBasedROI
from ._roi_base import InteractionModeMixIn
from ._roi_base import RoiInteractionMode


logger = logging.getLogger(__name__)


class _ArcGeometry:
    """
    Non-mutable object to store the geometry of the arc ROI.

    The aim is is to switch between consistent state without dealing with
    intermediate values.
    """

    def __init__(
        self,
        center,
        startPoint,
        endPoint,
        radius,
        weight,
        startAngle,
        endAngle,
        closed=False,
    ):
        """Constructor for a consistent arc geometry.

        There is also specific class method to create different kind of arc
        geometry.
        """
        self.center = center
        self.startPoint = startPoint
        self.endPoint = endPoint
        self.radius = radius
        self.weight = weight
        self.startAngle = startAngle
        self.endAngle = endAngle
        self._closed = closed

    @classmethod
    def createEmpty(cls):
        """Create an arc geometry from an empty shape"""
        zero = numpy.array([0, 0])
        return cls(zero, zero.copy(), zero.copy(), 0, 0, 0, 0)

    @classmethod
    def createRect(cls, startPoint, endPoint, weight):
        """Create an arc geometry from a definition of a rectangle"""
        return cls(None, startPoint, endPoint, None, weight, None, None, False)

    @classmethod
    def createCircle(
        cls, center, startPoint, endPoint, radius, weight, startAngle, endAngle
    ):
        """Create an arc geometry from a definition of a circle"""
        return cls(
            center, startPoint, endPoint, radius, weight, startAngle, endAngle, True
        )

    def withWeight(self, weight):
        """Return a new geometry based on this object, with a specific weight"""
        return _ArcGeometry(
            self.center,
            self.startPoint,
            self.endPoint,
            self.radius,
            weight,
            self.startAngle,
            self.endAngle,
            self._closed,
        )

    def withRadius(self, radius):
        """Return a new geometry based on this object, with a specific radius.

        The weight and the center is conserved.
        """
        startPoint = (
            self.center + (self.startPoint - self.center) / self.radius * radius
        )
        endPoint = self.center + (self.endPoint - self.center) / self.radius * radius
        return _ArcGeometry(
            self.center,
            startPoint,
            endPoint,
            radius,
            self.weight,
            self.startAngle,
            self.endAngle,
            self._closed,
        )

    def withStartAngle(self, startAngle):
        """Return a new geometry based on this object, with a specific start angle"""
        vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)])
        startPoint = self.center + vector * self.radius

        # Never add more than 180 to maintain coherency
        deltaAngle = startAngle - self.startAngle
        if deltaAngle > numpy.pi:
            deltaAngle -= numpy.pi * 2
        elif deltaAngle < -numpy.pi:
            deltaAngle += numpy.pi * 2

        startAngle = self.startAngle + deltaAngle
        return _ArcGeometry(
            self.center,
            startPoint,
            self.endPoint,
            self.radius,
            self.weight,
            startAngle,
            self.endAngle,
            self._closed,
        )

    def withEndAngle(self, endAngle):
        """Return a new geometry based on this object, with a specific end angle"""
        vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)])
        endPoint = self.center + vector * self.radius

        # Never add more than 180 to maintain coherency
        deltaAngle = endAngle - self.endAngle
        if deltaAngle > numpy.pi:
            deltaAngle -= numpy.pi * 2
        elif deltaAngle < -numpy.pi:
            deltaAngle += numpy.pi * 2

        endAngle = self.endAngle + deltaAngle
        return _ArcGeometry(
            self.center,
            self.startPoint,
            endPoint,
            self.radius,
            self.weight,
            self.startAngle,
            endAngle,
            self._closed,
        )

    def translated(self, dx, dy):
        """Return the translated geometry by dx, dy"""
        delta = numpy.array([dx, dy])
        center = None if self.center is None else self.center + delta
        startPoint = None if self.startPoint is None else self.startPoint + delta
        endPoint = None if self.endPoint is None else self.endPoint + delta
        return _ArcGeometry(
            center,
            startPoint,
            endPoint,
            self.radius,
            self.weight,
            self.startAngle,
            self.endAngle,
            self._closed,
        )

    def getKind(self):
        """Returns the kind of shape defined"""
        if self.center is None:
            return "rect"
        elif numpy.isnan(self.startAngle):
            return "point"
        elif self.isClosed():
            if self.weight <= 0 or self.weight * 0.5 >= self.radius:
                return "circle"
            else:
                return "donut"
        else:
            if self.weight * 0.5 < self.radius:
                return "arc"
            else:
                return "camembert"

    def isClosed(self):
        """Returns True if the geometry is a circle like"""
        if self._closed is not None:
            return self._closed
        delta = numpy.abs(self.endAngle - self.startAngle)
        self._closed = numpy.isclose(delta, numpy.pi * 2)
        return self._closed

    def __str__(self):
        return str(
            (
                self.center,
                self.startPoint,
                self.endPoint,
                self.radius,
                self.weight,
                self.startAngle,
                self.endAngle,
                self._closed,
            )
        )


class ArcROI(HandleBasedROI, items.LineMixIn, InteractionModeMixIn):
    """A ROI identifying an arc of a circle with a width.

    This ROI provides
    - 3 handle to control the curvature
    - 1 handle to control the weight
    - 1 anchor to translate the shape.
    """

    ICON = "add-shape-arc"
    NAME = "arc ROI"
    SHORT_NAME = "arc"
    """Metadata for this kind of ROI"""

    _plotShape = "line"
    """Plot shape which is used for the first interaction"""

    ThreePointMode = RoiInteractionMode(
        "3 points", "Provides 3 points to define the main radius circle"
    )
    PolarMode = RoiInteractionMode(
        "Polar", "Provides anchors to edit the ROI in polar coords"
    )
    # FIXME: MoveMode was designed cause there is too much anchors
    # FIXME: It would be good replace it by a dnd on the shape
    MoveMode = RoiInteractionMode(
        "Translation", "Provides anchors to only move the ROI"
    )

    class Role(enum.Enum):
        """Identify a set of roles which can be used for now to reach some positions"""

        START = 0
        """Location of the anchor at the start of the arc"""
        STOP = 1
        """Location of the anchor at the stop of the arc"""
        MIDDLE = 2
        """Location of the anchor at the middle of the arc"""
        CENTER = 3
        """Location of the center of the circle"""

    def __init__(self, parent=None):
        HandleBasedROI.__init__(self, parent=parent)
        items.LineMixIn.__init__(self)
        InteractionModeMixIn.__init__(self)

        self._geometry = _ArcGeometry.createEmpty()
        self._handleLabel = self.addLabelHandle()

        self._handleStart = self.addHandle()
        self._handleMid = self.addHandle()
        self._handleEnd = self.addHandle()
        self._handleWeight = self.addHandle()
        self._handleWeight._setConstraint(self._arcCurvatureMarkerConstraint)
        self._handleMove = self.addTranslateHandle()

        shape = items.Shape("polygon")
        shape.setPoints([[0, 0], [0, 0]])
        shape.setColor(rgba(self.getColor()))
        shape.setFill(False)
        shape.setOverlay(True)
        shape.setLineStyle(self.getLineStyle())
        shape.setLineWidth(self.getLineWidth())
        self.__shape = shape
        self.addItem(shape)

        self._initInteractionMode(self.ThreePointMode)
        self._interactiveModeUpdated(self.ThreePointMode)

    def availableInteractionModes(self):
        """Returns the list of available interaction modes

        :rtype: List[RoiInteractionMode]
        """
        return [self.ThreePointMode, self.PolarMode, self.MoveMode]

    def _interactiveModeUpdated(self, modeId):
        """Set the interaction mode.

        :param RoiInteractionMode modeId:
        """
        if modeId is self.ThreePointMode:
            self._handleStart.setVisible(True)
            self._handleEnd.setVisible(True)
            self._handleWeight.setVisible(True)
            self._handleStart.setSymbol("s")
            self._handleMid.setSymbol("s")
            self._handleEnd.setSymbol("s")
            self._handleWeight.setSymbol("d")
            self._handleMove.setSymbol("+")
        elif modeId is self.PolarMode:
            self._handleStart.setVisible(True)
            self._handleEnd.setVisible(True)
            self._handleWeight.setVisible(True)
            self._handleStart.setSymbol("o")
            self._handleMid.setSymbol("o")
            self._handleEnd.setSymbol("o")
            self._handleWeight.setSymbol("d")
            self._handleMove.setSymbol("+")
        elif modeId is self.MoveMode:
            self._handleStart.setVisible(False)
            self._handleEnd.setVisible(False)
            self._handleWeight.setVisible(False)
            self._handleMid.setSymbol("+")
            self._handleMove.setSymbol("+")
        else:
            assert False
        if self._geometry.isClosed():
            if modeId != self.MoveMode:
                self._handleStart.setSymbol("x")
                self._handleEnd.setSymbol("x")
        self._updateHandles()

    def _updated(self, event=None, checkVisibility=True):
        if event == items.ItemChangedType.VISIBLE:
            self._updateItemProperty(event, self, self.__shape)
        super(ArcROI, self)._updated(event, checkVisibility)

    def _updatedStyle(self, event, style):
        super(ArcROI, self)._updatedStyle(event, style)
        self.__shape.setColor(style.getColor())
        self.__shape.setLineStyle(style.getLineStyle())
        self.__shape.setLineWidth(style.getLineWidth())

    def setFirstShapePoints(self, points):
        """Initialize the ROI using the points from the first interaction.

        This interaction is constrained by the plot API and only supports few
        shapes.
        """
        # The first shape is a line
        point0 = points[0]
        point1 = points[1]

        # Compute a non collinear point for the curvature
        center = (point1 + point0) * 0.5
        normal = point1 - center
        normal = numpy.array((normal[1], -normal[0]))
        defaultCurvature = numpy.pi / 5.0
        weightCoef = 0.20
        mid = center - normal * defaultCurvature
        distance = numpy.linalg.norm(point0 - point1)
        weight = distance * weightCoef

        geometry = self._createGeometryFromControlPoints(point0, mid, point1, weight)
        self._geometry = geometry
        self._updateHandles()

    def _updateText(self, text):
        self._handleLabel.setText(text)

    def _updateMidHandle(self):
        """Keep the same geometry, but update the location of the control
        points.

        So calling this function do not trigger sigRegionChanged.
        """
        geometry = self._geometry

        if geometry.isClosed():
            start = numpy.array(self._handleStart.getPosition())
            midPos = geometry.center + geometry.center - start
        else:
            if geometry.center is None:
                midPos = geometry.startPoint * 0.5 + geometry.endPoint * 0.5
            else:
                midAngle = geometry.startAngle * 0.5 + geometry.endAngle * 0.5
                vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
                midPos = geometry.center + geometry.radius * vector

        with utils.blockSignals(self._handleMid):
            self._handleMid.setPosition(*midPos)

    def _updateWeightHandle(self):
        geometry = self._geometry
        if geometry.center is None:
            # rectangle
            center = (geometry.startPoint + geometry.endPoint) * 0.5
            normal = geometry.endPoint - geometry.startPoint
            normal = numpy.array((normal[1], -normal[0]))
            distance = numpy.linalg.norm(normal)
            if distance != 0:
                normal = normal / distance
            weightPos = center + normal * geometry.weight * 0.5
        else:
            if geometry.isClosed():
                midAngle = geometry.startAngle + numpy.pi * 0.5
            elif geometry.center is not None:
                midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
            vector = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
            weightPos = (
                geometry.center + (geometry.radius + geometry.weight * 0.5) * vector
            )

        with utils.blockSignals(self._handleWeight):
            self._handleWeight.setPosition(*weightPos)

    def _getWeightFromHandle(self, weightPos):
        geometry = self._geometry
        if geometry.center is None:
            # rectangle
            center = (geometry.startPoint + geometry.endPoint) * 0.5
            return numpy.linalg.norm(center - weightPos) * 2
        else:
            distance = numpy.linalg.norm(geometry.center - weightPos)
            return abs(distance - geometry.radius) * 2

    def _updateHandles(self):
        geometry = self._geometry
        with utils.blockSignals(self._handleStart):
            self._handleStart.setPosition(*geometry.startPoint)
        with utils.blockSignals(self._handleEnd):
            self._handleEnd.setPosition(*geometry.endPoint)

        self._updateMidHandle()
        self._updateWeightHandle()
        self._updateShape()

    def _updateCurvature(
        self, start, mid, end, updateCurveHandles, checkClosed=False, updateStart=False
    ):
        """Update the curvature using 3 control points in the curve

        :param bool updateCurveHandles: If False curve handles are already at
            the right location
        """
        if checkClosed:
            closed = self._isCloseInPixel(start, end)
        else:
            closed = self._geometry.isClosed()
        if closed:
            if updateStart:
                start = end
            else:
                end = start

        if updateCurveHandles:
            with utils.blockSignals(self._handleStart):
                self._handleStart.setPosition(*start)
            with utils.blockSignals(self._handleMid):
                self._handleMid.setPosition(*mid)
            with utils.blockSignals(self._handleEnd):
                self._handleEnd.setPosition(*end)

        weight = self._geometry.weight
        geometry = self._createGeometryFromControlPoints(
            start, mid, end, weight, closed=closed
        )
        self._geometry = geometry

        self._updateWeightHandle()
        self._updateShape()

    def _updateCloseInAngle(self, geometry, updateStart):
        azim = numpy.abs(geometry.endAngle - geometry.startAngle)
        if numpy.pi < azim < 3 * numpy.pi:
            closed = self._isCloseInPixel(geometry.startPoint, geometry.endPoint)
            geometry._closed = closed
            if closed:
                sign = 1 if geometry.startAngle < geometry.endAngle else -1
                if updateStart:
                    geometry.startPoint = geometry.endPoint
                    geometry.startAngle = geometry.endAngle - sign * 2 * numpy.pi
                else:
                    geometry.endPoint = geometry.startPoint
                    geometry.endAngle = geometry.startAngle + sign * 2 * numpy.pi

    def handleDragUpdated(self, handle, origin, previous, current):
        modeId = self.getInteractionMode()
        if handle is self._handleStart:
            if modeId is self.ThreePointMode:
                mid = numpy.array(self._handleMid.getPosition())
                end = numpy.array(self._handleEnd.getPosition())
                self._updateCurvature(
                    current,
                    mid,
                    end,
                    checkClosed=True,
                    updateStart=True,
                    updateCurveHandles=False,
                )
            elif modeId is self.PolarMode:
                v = current - self._geometry.center
                startAngle = numpy.angle(complex(v[0], v[1]))
                geometry = self._geometry.withStartAngle(startAngle)
                self._updateCloseInAngle(geometry, updateStart=True)
                self._geometry = geometry
                self._updateHandles()
        elif handle is self._handleMid:
            if modeId is self.ThreePointMode:
                if self._geometry.isClosed():
                    radius = numpy.linalg.norm(self._geometry.center - current)
                    self._geometry = self._geometry.withRadius(radius)
                    self._updateHandles()
                else:
                    start = numpy.array(self._handleStart.getPosition())
                    end = numpy.array(self._handleEnd.getPosition())
                    self._updateCurvature(start, current, end, updateCurveHandles=False)
            elif modeId is self.PolarMode:
                radius = numpy.linalg.norm(self._geometry.center - current)
                self._geometry = self._geometry.withRadius(radius)
                self._updateHandles()
            elif modeId is self.MoveMode:
                delta = current - previous
                self.translate(*delta)
        elif handle is self._handleEnd:
            if modeId is self.ThreePointMode:
                start = numpy.array(self._handleStart.getPosition())
                mid = numpy.array(self._handleMid.getPosition())
                self._updateCurvature(
                    start,
                    mid,
                    current,
                    checkClosed=True,
                    updateStart=False,
                    updateCurveHandles=False,
                )
            elif modeId is self.PolarMode:
                v = current - self._geometry.center
                endAngle = numpy.angle(complex(v[0], v[1]))
                geometry = self._geometry.withEndAngle(endAngle)
                self._updateCloseInAngle(geometry, updateStart=False)
                self._geometry = geometry
                self._updateHandles()
        elif handle is self._handleWeight:
            weight = self._getWeightFromHandle(current)
            self._geometry = self._geometry.withWeight(weight)
            self._updateShape()
        elif handle is self._handleMove:
            delta = current - previous
            self.translate(*delta)

    def _isCloseInPixel(self, point1, point2):
        manager = self.parent()
        if manager is None:
            return False
        plot = manager.parent()
        if plot is None:
            return False
        point1 = plot.dataToPixel(*point1)
        if point1 is None:
            return False
        point2 = plot.dataToPixel(*point2)
        if point2 is None:
            return False
        return abs(point1[0] - point2[0]) + abs(point1[1] - point2[1]) < 15

    def _normalizeGeometry(self):
        """Keep the same phisical geometry, but with normalized parameters."""
        geometry = self._geometry
        if geometry.weight * 0.5 >= geometry.radius:
            radius = (geometry.weight * 0.5 + geometry.radius) * 0.5
            geometry = geometry.withRadius(radius)
            geometry = geometry.withWeight(radius * 2)
            self._geometry = geometry
            return True
        return False

    def handleDragFinished(self, handle, origin, current):
        modeId = self.getInteractionMode()
        if handle in [self._handleStart, self._handleMid, self._handleEnd]:
            if modeId is self.ThreePointMode:
                self._normalizeGeometry()
                self._updateHandles()

        if self._geometry.isClosed():
            if modeId is self.MoveMode:
                self._handleStart.setSymbol("")
                self._handleEnd.setSymbol("")
            else:
                self._handleStart.setSymbol("x")
                self._handleEnd.setSymbol("x")
        else:
            if modeId is self.ThreePointMode:
                self._handleStart.setSymbol("s")
                self._handleEnd.setSymbol("s")
            elif modeId is self.PolarMode:
                self._handleStart.setSymbol("o")
                self._handleEnd.setSymbol("o")
            if modeId is self.MoveMode:
                self._handleStart.setSymbol("")
                self._handleEnd.setSymbol("")

    def _createGeometryFromControlPoints(self, start, mid, end, weight, closed=None):
        """Returns the geometry of the object"""
        if closed or (closed is None and numpy.allclose(start, end)):
            # Special arc: It's a closed circle
            center = (start + mid) * 0.5
            radius = numpy.linalg.norm(start - center)
            v = start - center
            startAngle = numpy.angle(complex(v[0], v[1]))
            endAngle = startAngle + numpy.pi * 2.0
            return _ArcGeometry.createCircle(
                center, start, end, radius, weight, startAngle, endAngle
            )

        elif (
            numpy.linalg.norm(
                numpy.cross(numpy.append(mid - start, 0), numpy.append(end - start, 0))
            )
            < 1e-5
        ):
            # Degenerated arc, it's a rectangle
            return _ArcGeometry.createRect(start, end, weight)
        else:
            center, radius = self._circleEquation(start, mid, end)
            v = start - center
            startAngle = numpy.angle(complex(v[0], v[1]))
            v = mid - center
            midAngle = numpy.angle(complex(v[0], v[1]))
            v = end - center
            endAngle = numpy.angle(complex(v[0], v[1]))

            # Is it clockwise or anticlockwise
            relativeMid = (endAngle - midAngle + 2 * numpy.pi) % (2 * numpy.pi)
            relativeEnd = (endAngle - startAngle + 2 * numpy.pi) % (2 * numpy.pi)
            if relativeMid < relativeEnd:
                if endAngle < startAngle:
                    endAngle += 2 * numpy.pi
            else:
                if endAngle > startAngle:
                    endAngle -= 2 * numpy.pi

            return _ArcGeometry(
                center, start, end, radius, weight, startAngle, endAngle
            )

    def _createShapeFromGeometry(self, geometry):
        kind = geometry.getKind()
        if kind == "rect":
            # It is not an arc
            # but we can display it as an intermediate shape
            normal = geometry.endPoint - geometry.startPoint
            normal = numpy.array((normal[1], -normal[0]))
            distance = numpy.linalg.norm(normal)
            if distance != 0:
                normal /= distance
            points = numpy.array(
                [
                    geometry.startPoint + normal * geometry.weight * 0.5,
                    geometry.endPoint + normal * geometry.weight * 0.5,
                    geometry.endPoint - normal * geometry.weight * 0.5,
                    geometry.startPoint - normal * geometry.weight * 0.5,
                ]
            )
        elif kind == "point":
            # It is not an arc
            # but we can display it as an intermediate shape
            # NOTE: At least 2 points are expected
            points = numpy.array([geometry.startPoint, geometry.startPoint])
        elif kind == "circle":
            outerRadius = geometry.radius + geometry.weight * 0.5
            angles = numpy.linspace(0, 2 * numpy.pi, num=50)
            # It's a circle
            points = []
            numpy.append(angles, angles[-1])
            for angle in angles:
                direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
                points.append(geometry.center + direction * outerRadius)
            points = numpy.array(points)
        elif kind == "donut":
            innerRadius = geometry.radius - geometry.weight * 0.5
            outerRadius = geometry.radius + geometry.weight * 0.5
            angles = numpy.linspace(0, 2 * numpy.pi, num=50)
            # It's a donut
            points = []
            # NOTE: NaN value allow to create 2 separated circle shapes
            # using a single plot item. It's a kind of cheat
            points.append(numpy.array([float("nan"), float("nan")]))
            for angle in angles:
                direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
                points.insert(0, geometry.center + direction * innerRadius)
                points.append(geometry.center + direction * outerRadius)
            points.append(numpy.array([float("nan"), float("nan")]))
            points = numpy.array(points)
        else:
            innerRadius = geometry.radius - geometry.weight * 0.5
            outerRadius = geometry.radius + geometry.weight * 0.5

            sign = numpy.sign(geometry.endAngle - geometry.startAngle)
            delta = min(0.1, abs(geometry.startAngle - geometry.endAngle) / 100) * sign

            if geometry.startAngle == geometry.endAngle:
                # Degenerated, it's a line (single radius)
                angle = geometry.startAngle
                direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
                points = []
                points.append(geometry.center + direction * innerRadius)
                points.append(geometry.center + direction * outerRadius)
                return numpy.array(points)

            angles = numpy.arange(geometry.startAngle, geometry.endAngle, delta)
            if angles[-1] != geometry.endAngle:
                angles = numpy.append(angles, geometry.endAngle)

            if kind == "camembert":
                # It's a part of camembert
                points = []
                points.append(geometry.center)
                points.append(geometry.startPoint)
                for angle in angles:
                    direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
                    points.append(geometry.center + direction * outerRadius)
                points.append(geometry.endPoint)
                points.append(geometry.center)
            elif kind == "arc":
                # It's a part of donut
                points = []
                points.append(geometry.startPoint)
                for angle in angles:
                    direction = numpy.array([numpy.cos(angle), numpy.sin(angle)])
                    points.insert(0, geometry.center + direction * innerRadius)
                    points.append(geometry.center + direction * outerRadius)
                points.insert(0, geometry.endPoint)
                points.append(geometry.endPoint)
            else:
                assert False

            points = numpy.array(points)

        return points

    def _updateShape(self):
        geometry = self._geometry
        points = self._createShapeFromGeometry(geometry)
        self.__shape.setPoints(points)

        index = numpy.nanargmin(points[:, 1])
        pos = points[index]
        with utils.blockSignals(self._handleLabel):
            self._handleLabel.setPosition(pos[0], pos[1])

        if geometry.center is None:
            movePos = geometry.startPoint * 0.34 + geometry.endPoint * 0.66
        else:
            movePos = geometry.center

        with utils.blockSignals(self._handleMove):
            self._handleMove.setPosition(*movePos)

        self.sigRegionChanged.emit()

    def getGeometry(self):
        """Returns a tuple containing the geometry of this ROI

        It is a symmetric function of :meth:`setGeometry`.

        If `startAngle` is smaller than `endAngle` the rotation is clockwise,
        else the rotation is anticlockwise.

        :rtype: Tuple[numpy.ndarray,float,float,float,float]
        :raise ValueError: In case the ROI can't be represented as section of
            a circle
        """
        geometry = self._geometry
        if geometry.center is None:
            raise ValueError("This ROI can't be represented as a section of circle")
        return (
            geometry.center,
            self.getInnerRadius(),
            self.getOuterRadius(),
            geometry.startAngle,
            geometry.endAngle,
        )

    def getPosition(self, role: Role = Role.CENTER) -> Tuple[float, float]:
        """Returns a position by it's role.

        By default returns the center of the circle of the arc ROI.
        """
        if role == self.Role.START:
            return self._handleStart.getPosition()
        if role == self.Role.STOP:
            return self._handleEnd.getPosition()
        if role == self.Role.MIDDLE:
            return self._handleMid.getPosition()
        if role == self.Role.CENTER:
            p = self.getCenter()
            return p[0], p[1]
        raise ValueError(f"{role} is not supported")

    def isClosed(self):
        """Returns true if the arc is a closed shape, like a circle or a donut.

        :rtype: bool
        """
        return self._geometry.isClosed()

    def getCenter(self):
        """Returns the center of the circle used to draw arcs of this ROI.

        This center is usually outside the the shape itself.

        :rtype: numpy.ndarray
        """
        return self._geometry.center

    def getStartAngle(self):
        """Returns the angle of the start of the section of this ROI (in radian).

        If `startAngle` is smaller than `endAngle` the rotation is clockwise,
        else the rotation is anticlockwise.

        :rtype: float
        """
        return self._geometry.startAngle

    def getEndAngle(self):
        """Returns the angle of the end of the section of this ROI (in radian).

        If `startAngle` is smaller than `endAngle` the rotation is clockwise,
        else the rotation is anticlockwise.

        :rtype: float
        """
        return self._geometry.endAngle

    def getInnerRadius(self):
        """Returns the radius of the smaller arc used to draw this ROI.

        :rtype: float
        """
        geometry = self._geometry
        radius = geometry.radius - geometry.weight * 0.5
        if radius < 0:
            radius = 0
        return radius

    def getOuterRadius(self):
        """Returns the radius of the bigger arc used to draw this ROI.

        :rtype: float
        """
        geometry = self._geometry
        radius = geometry.radius + geometry.weight * 0.5
        return radius

    def setGeometry(self, center, innerRadius, outerRadius, startAngle, endAngle):
        """
        Set the geometry of this arc.

        :param numpy.ndarray center: Center of the circle.
        :param float innerRadius: Radius of the smaller arc of the section.
        :param float outerRadius: Weight of the bigger arc of the section.
            It have to be bigger than `innerRadius`
        :param float startAngle: Location of the start of the section (in radian)
        :param float endAngle: Location of the end of the section (in radian).
            If `startAngle` is smaller than `endAngle` the rotation is clockwise,
            else the rotation is anticlockwise.
        """
        if innerRadius > outerRadius:
            logger.error("inner radius larger than outer radius")
            innerRadius, outerRadius = outerRadius, innerRadius
        center = numpy.array(center)
        radius = (innerRadius + outerRadius) * 0.5
        weight = outerRadius - innerRadius

        vector = numpy.array([numpy.cos(startAngle), numpy.sin(startAngle)])
        startPoint = center + vector * radius
        vector = numpy.array([numpy.cos(endAngle), numpy.sin(endAngle)])
        endPoint = center + vector * radius

        geometry = _ArcGeometry(
            center,
            startPoint,
            endPoint,
            radius,
            weight,
            startAngle,
            endAngle,
            closed=None,
        )
        self._geometry = geometry
        self._updateHandles()

    @docstring(HandleBasedROI)
    def contains(self, position):
        # first check distance, fastest
        center = self.getCenter()
        distance = numpy.sqrt(
            (position[1] - center[1]) ** 2 + ((position[0] - center[0])) ** 2
        )
        is_in_distance = self.getInnerRadius() <= distance <= self.getOuterRadius()
        if not is_in_distance:
            return False
        rel_pos = position[1] - center[1], position[0] - center[0]
        angle = numpy.arctan2(*rel_pos)
        # angle is inside [-pi, pi]

        # Normalize the start angle between [-pi, pi]
        # with a positive angle range
        start_angle = self.getStartAngle()
        end_angle = self.getEndAngle()
        azim_range = end_angle - start_angle
        if azim_range < 0:
            start_angle = end_angle
            azim_range = -azim_range
        start_angle = numpy.mod(start_angle + numpy.pi, 2 * numpy.pi) - numpy.pi

        if angle < start_angle:
            angle += 2 * numpy.pi
        return start_angle <= angle <= start_angle + azim_range

    def translate(self, x, y):
        self._geometry = self._geometry.translated(x, y)
        self._updateHandles()

    def _arcCurvatureMarkerConstraint(self, x, y):
        """Curvature marker remains on perpendicular bisector"""
        geometry = self._geometry
        if geometry.center is None:
            center = (geometry.startPoint + geometry.endPoint) * 0.5
            vector = geometry.startPoint - geometry.endPoint
            vector = numpy.array((vector[1], -vector[0]))
            vdist = numpy.linalg.norm(vector)
            if vdist != 0:
                normal = numpy.array((vector[1], -vector[0])) / vdist
            else:
                normal = numpy.array((0, 0))
        else:
            if geometry.isClosed():
                midAngle = geometry.startAngle + numpy.pi * 0.5
            else:
                midAngle = (geometry.startAngle + geometry.endAngle) * 0.5
            normal = numpy.array([numpy.cos(midAngle), numpy.sin(midAngle)])
            center = geometry.center
        dist = numpy.dot(normal, (numpy.array((x, y)) - center))
        dist = numpy.clip(dist, geometry.radius, geometry.radius * 2)
        x, y = center + dist * normal
        return x, y

    @staticmethod
    def _circleEquation(pt1, pt2, pt3):
        """Circle equation from 3 (x, y) points

        :return: Position of the center of the circle and the radius
        :rtype: Tuple[Tuple[float,float],float]
        """
        x, y, z = complex(*pt1), complex(*pt2), complex(*pt3)
        w = z - x
        w /= y - x
        c = (x - y) * (w - abs(w) ** 2) / 2j / w.imag - x
        return numpy.array((-c.real, -c.imag)), abs(c + x)

    def __str__(self):
        try:
            center, innerRadius, outerRadius, startAngle, endAngle = self.getGeometry()
            params = (
                center[0],
                center[1],
                innerRadius,
                outerRadius,
                startAngle,
                endAngle,
            )
            params = "center: %f %f; radius: %f %f; angles: %f %f" % params
        except ValueError:
            params = "invalid"
        return "%s(%s)" % (self.__class__.__name__, params)
