Source code for plotpy.items.shape.ellipse

# -*- coding: utf-8 -*-

from __future__ import annotations

import math
import sys
from typing import TYPE_CHECKING

import numpy as np
from guidata.utils.misc import assert_interfaces_valid
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qwt import QwtSymbol

from plotpy.items.shape.polygon import PolygonShape
from plotpy.mathutils.geometry import compute_angle, compute_center

if TYPE_CHECKING:
    import qwt.scale_map
    from qtpy.QtCore import QLineF, QPointF, QRectF
    from qtpy.QtGui import QPainter, QPolygonF

    from plotpy.styles.shape import ShapeParam


[docs] class EllipseShape(PolygonShape): """Ellipse shape Args: x1: X coordinate of the first point y1: Y coordinate of the first point x2: X coordinate of the second point y2: Y coordinate of the second point shapeparam: Shape parameters """ CLOSED = True _icon_name = "circle.png" def __init__( self, x1: float = 0.0, y1: float = 0.0, x2: float = 0.0, y2: float = 0.0, shapeparam: ShapeParam = None, ) -> None: super().__init__(shapeparam=shapeparam) self.is_ellipse = False self.set_xdiameter(x1, y1, x2, y2)
[docs] def switch_to_ellipse(self): """Switch to ellipse mode""" self.is_ellipse = True self.set_icon_name("ellipse_shape.png")
[docs] def switch_to_circle(self): """Switch to circle mode""" self.is_ellipse = False self.set_icon_name("circle.png")
[docs] def set_xdiameter(self, x0: float, y0: float, x1: float, y1: float) -> None: """Set the coordinates of the ellipse's X-axis diameter Args: x0: X coordinate of the first point y0: Y coordinate of the first point x1: X coordinate of the second point y1: Y coordinate of the second point """ xline = QC.QLineF(x0, y0, x1, y1) yline = xline.normalVector() yline.translate(xline.pointAt(0.5) - xline.p1()) if self.is_ellipse: yline.setLength(self.get_yline().length()) else: yline.setLength(xline.length()) yline.translate(yline.pointAt(0.5) - yline.p2()) self.set_points( [(x0, y0), (x1, y1), (yline.x1(), yline.y1()), (yline.x2(), yline.y2())] )
[docs] def get_xdiameter(self) -> tuple[tuple[float, float], tuple[float, float]]: """Return the coordinates of the ellipse's X-axis diameter Returns: Tuple with two tuples of floats """ return tuple(self.points[0]) + tuple(self.points[1])
[docs] def set_ydiameter(self, x2: float, y2: float, x3: float, y3: float) -> None: """Set the coordinates of the ellipse's Y-axis diameter Args: x2: X coordinate of the first point y2: Y coordinate of the first point x3: X coordinate of the second point y3: Y coordinate of the second point """ yline = QC.QLineF(x2, y2, x3, y3) xline = yline.normalVector() xline.translate(yline.pointAt(0.5) - yline.p1()) if self.is_ellipse: xline.setLength(self.get_xline().length()) xline.translate(xline.pointAt(0.5) - xline.p2()) self.set_points( [(xline.x2(), xline.y2()), (xline.x1(), xline.y1()), (x2, y2), (x3, y3)] )
[docs] def get_ydiameter(self) -> tuple[tuple[float, float], tuple[float, float]]: """Return the coordinates of the ellipse's Y-axis diameter Returns: Tuple with two tuples of floats """ return tuple(self.points[2]) + tuple(self.points[3])
[docs] def get_rect(self) -> tuple[float, float, float, float]: """Get the bounding rectangle of the shape Returns: Tuple with four floats .. warning:: This method is valid for circle only! """ assert not self.is_ellipse (x0, y0), (x1, y1) = self.points[0], self.points[1] xc, yc = 0.5 * (x0 + x1), 0.5 * (y0 + y1) radius = 0.5 * np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) return xc - radius, yc - radius, xc + radius, yc + radius
[docs] def boundingRect(self) -> QC.QRectF: """Return the bounding rectangle of the shape Returns: Bounding rectangle of the shape """ # See https://stackoverflow.com/a/14163413 (x0, y0), (x1, y1) = self.points[0], self.points[1] radius1 = 0.5 * np.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) (x2, y2), (x3, y3) = self.points[2], self.points[3] radius2 = 0.5 * np.sqrt((x3 - x2) ** 2 + (y3 - y2) ** 2) xc, yc = 0.5 * (x0 + x1), 0.5 * (y0 + y1) phi = math.radians(compute_angle(xc, yc, x1, y1)) ux = radius1 * math.cos(phi) uy = radius1 * math.sin(phi) vx = radius2 * math.cos(phi + math.pi / 2) vy = radius2 * math.sin(phi + math.pi / 2) bbox_halfwidth = math.sqrt(ux * ux + vx * vx) bbox_halfheight = math.sqrt(uy * uy + vy * vy) return QC.QRectF( xc - bbox_halfwidth, yc - bbox_halfheight, 2 * bbox_halfwidth, 2 * bbox_halfheight, )
[docs] def get_center(self) -> tuple[float, float]: """Return center coordinates Returns: Tuple with two floats (x, y) """ return compute_center(*self.get_xdiameter())
[docs] def set_rect(self, x0: float, y0: float, x1: float, y1: float) -> None: """Set the bounding rectangle of the shape Args: x0: X coordinate of the first point y0: Y coordinate of the first point x1: X coordinate of the second point y1: Y coordinate of the second point .. warning:: This method is valid for circle only! """ assert not self.is_ellipse self.set_xdiameter(x0, 0.5 * (y0 + y1), x1, 0.5 * (y0 + y1))
[docs] def compute_elements( self, xMap: qwt.scale_map.QwtScaleMap, yMap: qwt.scale_map.QwtScaleMap ) -> tuple[QPolygonF, QLineF, QLineF, QRectF]: """Return points, lines and ellipse rect Args: xMap: X axis scale map yMap: Y axis scale map Returns: Tuple with four elements (points, line0, line1, rect) """ points = self.transform_points(xMap, yMap) line0 = QC.QLineF(points[0], points[1]) line1 = QC.QLineF(points[2], points[3]) rect = QC.QRectF() rect.setWidth(line0.length()) rect.setHeight(line1.length()) rect.moveCenter(line0.pointAt(0.5)) return points, line0, line1, rect
[docs] def hit_test(self, pos: QPointF) -> tuple[float, float, bool, None]: """Return a tuple (distance, attach point, inside, other_object) Args: pos: Position Returns: tuple: Tuple with four elements: (distance, attach point, inside, other_object). Description of the returned values: * distance: distance in pixels (canvas coordinates) to the closest attach point * attach point: handle of the attach point * inside: True if the mouse button has been clicked inside the object * other_object: if not None, reference of the object which will be considered as hit instead of self """ if not self.plot(): return sys.maxsize, 0, False, None dist, handle, inside, other = self.poly_hit_test( self.plot(), self.xAxis(), self.yAxis(), pos ) if not inside: xMap = self.plot().canvasMap(self.xAxis()) yMap = self.plot().canvasMap(self.yAxis()) _points, _line0, _line1, rect = self.compute_elements(xMap, yMap) inside = rect.contains(QC.QPointF(pos)) return dist, handle, inside, other
[docs] def draw( self, painter: QPainter, xMap: qwt.scale_map.QwtScaleMap, yMap: qwt.scale_map.QwtScaleMap, canvasRect: QRectF, ) -> None: """Draw the item Args: painter: Painter xMap: X axis scale map yMap: Y axis scale map canvasRect: Canvas rectangle """ points, line0, line1, rect = self.compute_elements(xMap, yMap) pen, brush, symbol = self.get_pen_brush(xMap, yMap) painter.setRenderHint(QG.QPainter.Antialiasing) painter.setPen(pen) painter.setBrush(brush) # Draw the axes lines connecting handles painter.drawLine(line0) painter.drawLine(line1) # For the ellipse, we need to handle non-uniform aspect ratios properly. # The ellipse should have its semi-axes aligned with line0 and line1 directions. # We use QPainterPath to draw an ellipse transformed to match the actual # geometry. center = line0.pointAt(0.5) # Create the ellipse in a local coordinate system where line0 is horizontal # and line1 is vertical, then transform it to match the actual geometry path = QG.QPainterPath() # Calculate the angle between line0 and line1 in pixel space # (they should be 90° in data space but may differ after transformation) angle0 = math.radians(line0.angle()) angle1 = math.radians(line1.angle()) # Semi-axes lengths a = line0.length() / 2 # semi-major axis (along line0) b = line1.length() / 2 # semi-minor axis (along line1) # Draw ellipse using parametric form, accounting for non-perpendicular axes # We sample points around the ellipse and create a path n_points = 72 # Number of points for smooth ellipse first_point = True for i in range(n_points + 1): t = 2 * math.pi * i / n_points # Parametric ellipse with potentially non-perpendicular axes # Point = center + cos(t) * a * dir0 + sin(t) * b * dir1 dx = math.cos(t) * a * math.cos(angle0) + math.sin(t) * b * math.cos(angle1) dy = -math.cos(t) * a * math.sin(angle0) - math.sin(t) * b * math.sin( angle1 ) px = center.x() + dx py = center.y() + dy if first_point: path.moveTo(px, py) first_point = False else: path.lineTo(px, py) path.closeSubpath() painter.drawPath(path) if symbol != QwtSymbol.NoSymbol: for i in range(points.size()): symbol.drawSymbol(painter, points[i].toPoint())
[docs] def get_xline(self) -> QC.QLineF: """Get the X axis diameter Returns: X axis diameter """ return QC.QLineF(*(tuple(self.points[0]) + tuple(self.points[1])))
[docs] def get_yline(self) -> QC.QLineF: """Get the Y axis diameter Returns: Y axis diameter """ return QC.QLineF(*(tuple(self.points[2]) + tuple(self.points[3])))
[docs] def move_point_to( self, handle: int, pos: tuple[float, float], ctrl: bool = False ) -> None: """Move a handle as returned by hit_test to the new position Args: handle: Handle pos: Position ctrl: True if <Ctrl> button is being pressed, False otherwise """ nx, ny = pos if handle == 0: x1, y1 = self.points[1] if ctrl: # When <Ctrl> is pressed, the center position is unchanged x0, y0 = self.points[0] x1, y1 = x1 + x0 - nx, y1 + y0 - ny self.set_xdiameter(nx, ny, x1, y1) elif handle == 1: x0, y0 = self.points[0] if ctrl: # When <Ctrl> is pressed, the center position is unchanged x1, y1 = self.points[1] x0, y0 = x0 + x1 - nx, y0 + y1 - ny self.set_xdiameter(x0, y0, nx, ny) elif handle == 2: x3, y3 = self.points[3] if ctrl: # When <Ctrl> is pressed, the center position is unchanged x2, y2 = self.points[2] x3, y3 = x3 + x2 - nx, y3 + y2 - ny self.set_ydiameter(nx, ny, x3, y3) elif handle == 3: x2, y2 = self.points[2] if ctrl: # When <Ctrl> is pressed, the center position is unchanged x3, y3 = self.points[3] x2, y2 = x2 + x3 - nx, y2 + y3 - ny self.set_ydiameter(x2, y2, nx, ny) elif handle == -1: delta = (nx, ny) - self.points.mean(axis=0) self.points += delta
def __reduce__(self) -> tuple: """Return a tuple used by pickle""" state = (self.shapeparam, self.points, self.z()) return (self.__class__, (), state) def __setstate__(self, state: tuple) -> None: """Set the state of the object from a tuple""" self.shapeparam, self.points, z = state self.setZ(z) self.shapeparam.update_item(self)
assert_interfaces_valid(EllipseShape)