# -*- coding: utf-8 -*-
from __future__ import annotations
import math
import sys
from typing import TYPE_CHECKING
import numpy as np
from guidata.dataset import update_dataset
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.config import CONF, _
from plotpy.coords import canvas_to_axes
from plotpy.interfaces import IBasePlotItem, ISerializableType, IShapeItemType
from plotpy.items.shape.base import AbstractShape
from plotpy.styles.shape import ShapeParam
if TYPE_CHECKING:
import guidata.io
import qwt.scale_map
import qwt.symbol
from qtpy.QtCore import QPointF, QRectF
from qtpy.QtGui import QBrush, QPainter, QPen, QPolygonF
from plotpy.interfaces import IItemType
from plotpy.plot.base import BasePlot
from plotpy.styles.base import ItemParameters
[docs]
class PolygonShape(AbstractShape):
"""Polygon shape class
Args:
points: List of point coordinates
closed: True if the polygon is closed, False otherwise
shapeparam: Shape parameters
"""
__implements__ = (IBasePlotItem, ISerializableType)
ADDITIONNAL_POINTS = 0 # Number of points which are not part of the shape
LINK_ADDITIONNAL_POINTS = False # Link additionnal points with dotted lines
CLOSED = True
_icon_name = "polygon.png"
def __init__(
self,
points: list[tuple[float, float]] | np.ndarray | None = None,
closed: bool | None = None,
shapeparam: ShapeParam | None = None,
) -> None:
super().__init__()
self.closed = self.CLOSED if closed is None else closed
self.selected = False
if shapeparam is None:
self.shapeparam = ShapeParam(_("Shape"), icon="rectangle.png")
else:
self.shapeparam = shapeparam
self.shapeparam.update_item(self)
self.pen = QG.QPen()
self.brush = QG.QBrush()
self.symbol = QwtSymbol.NoSymbol
self.sel_pen = QG.QPen()
self.sel_brush = QG.QBrush()
self.sel_symbol = QwtSymbol.NoSymbol
self.points: np.ndarray | None = None
self.set_points(points)
[docs]
def types(self) -> tuple[type[IItemType], ...]:
"""Returns a group or category for this item.
This should be a tuple of class objects inheriting from IItemType
Returns:
tuple: Tuple of class objects inheriting from IItemType
"""
return (IShapeItemType, ISerializableType)
def __reduce__(self) -> tuple[type, tuple, tuple]:
"""Return state information for pickling"""
self.shapeparam.update_param(self)
state = (self.shapeparam, self.points, self.closed, self.z())
return (PolygonShape, (), state)
def __setstate__(self, state: tuple) -> None:
"""Set state information for unpickling"""
self.shapeparam, self.points, self.closed, z = state
self.setZ(z)
self.shapeparam.update_item(self)
[docs]
def serialize(
self,
writer: guidata.io.HDF5Writer | guidata.io.INIWriter | guidata.io.JSONWriter,
) -> None:
"""Serialize object to HDF5 writer
Args:
writer: HDF5, INI or JSON writer
"""
self.shapeparam.update_param(self)
writer.write(self.shapeparam, group_name="shapeparam")
writer.write(self.points, group_name="points")
writer.write(self.closed, group_name="closed")
writer.write(self.z(), group_name="z")
[docs]
def deserialize(
self,
reader: guidata.io.HDF5Reader | guidata.io.INIReader | guidata.io.JSONReader,
) -> None:
"""Deserialize object from HDF5 reader
Args:
reader: HDF5, INI or JSON reader
"""
self.closed = reader.read("closed")
self.shapeparam = ShapeParam(_("Shape"), icon="rectangle.png")
reader.read("shapeparam", instance=self.shapeparam)
self.shapeparam.update_item(self)
self.points = reader.read(group_name="points", func=reader.read_array)
self.setZ(reader.read("z"))
# ----Public API-------------------------------------------------------------
[docs]
def set_style(self, section: str, option: str) -> None:
"""Set style for this item
Args:
section: Section
option: Option
"""
self.shapeparam.read_config(CONF, section, option)
self.shapeparam.update_item(self)
[docs]
def set_points(self, points: list[tuple[float, float]] | np.ndarray | None) -> None:
"""Set polygon points
Args:
points: List of point coordinates
"""
if points is None:
self.points = np.zeros((0, 2), float)
else:
self.points = np.array(points, float)
assert self.points.shape[1] == 2
[docs]
def get_points(self) -> np.ndarray:
"""Return polygon points
Returns:
Polygon points (array of shape (N, 2))
"""
return self.points
[docs]
def set_closed(self, state: bool) -> None:
"""Set closed state
Args:
state: True if the polygon is closed, False otherwise
"""
self.closed = state
[docs]
def is_closed(self) -> bool:
"""Return True if the polygon is closed, False otherwise
Returns:
True if the polygon is closed, False otherwise
"""
return self.closed
[docs]
def get_center(self) -> tuple[float, float]:
"""Return the center of the polygon
Returns:
Center of the polygon
"""
if self.points is not None and self.points.size > 0:
return self.points.mean(axis=0)
return 0.0, 0.0
[docs]
def boundingRect(self) -> QC.QRectF:
"""Return the bounding rectangle of the data
Returns:
Bounding rectangle of the data
"""
poly = QG.QPolygonF()
if self.ADDITIONNAL_POINTS:
shape_points = self.points[: -self.ADDITIONNAL_POINTS]
else:
shape_points = self.points
for i in range(shape_points.shape[0]):
poly.append(QC.QPointF(shape_points[i, 0], shape_points[i, 1]))
return poly.boundingRect()
[docs]
def is_empty(self) -> bool:
"""Return True if the item is empty
Returns:
True if the item is empty, False otherwise
"""
return len(self.points) == 0
[docs]
def get_bounding_rect_coords(self) -> tuple[float, float, float, float]:
"""Return bounding rectangle coordinates (in plot coordinates)
Returns:
Bounding rectangle coordinates (in plot coordinates)
"""
poly = QG.QPolygonF()
shape_points = self.points[: -self.ADDITIONNAL_POINTS]
for i in range(shape_points.shape[0]):
poly.append(QC.QPointF(shape_points[i, 0], shape_points[i, 1]))
return poly.boundingRect().getCoords()
[docs]
def get_reference_point(self) -> tuple[float, float] | None:
"""Return a reference point for the item
Returns:
Reference point for the item
"""
if self.points.size:
return self.points.mean(axis=0)
[docs]
def get_pen_brush(
self, xMap: qwt.scale_map.QwtScaleMap, yMap: qwt.scale_map.QwtScaleMap
) -> tuple[QPen, QBrush, qwt.symbol.QwtSymbol]:
"""Get pen, brush and symbol for the item
Args:
xMap: X axis scale map
yMap: Y axis scale map
Returns:
Tuple with pen, brush and symbol for the item
"""
if self.selected:
pen = self.sel_pen
brush = self.sel_brush
sym = self.sel_symbol
else:
pen = self.pen
brush = self.brush
sym = self.symbol
if self.points.size > 0:
x0, y0 = self.get_reference_point()
xx0 = xMap.transform(x0)
yy0 = yMap.transform(y0)
try:
# Optimized version in PyQt >= v4.5
t0 = QG.QTransform.fromTranslate(xx0, yy0)
except AttributeError:
# Fallback for PyQt <= v4.4
t0 = QG.QTransform().translate(xx0, yy0)
tr = brush.transform()
tr = tr * t0
brush = QG.QBrush(brush)
brush.setTransform(tr)
return pen, brush, sym
[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
"""
pen, brush, symbol = self.get_pen_brush(xMap, yMap)
painter.setRenderHint(QG.QPainter.Antialiasing)
painter.setPen(pen)
painter.setBrush(brush)
points = self.transform_points(xMap, yMap)
if self.ADDITIONNAL_POINTS:
# Slice indexing is not supported by PySide6, so we convert the `QPolygonF`
# object to a list before converting it back to a `QPolygonF` object:
shape_points = QG.QPolygonF(list(points)[: -self.ADDITIONNAL_POINTS])
other_points = QG.QPolygonF(list(points)[-self.ADDITIONNAL_POINTS :])
else:
shape_points = points
other_points = []
if self.closed:
painter.drawPolygon(shape_points)
else:
painter.drawPolyline(shape_points)
if symbol != QwtSymbol.NoSymbol:
symbol.drawSymbols(painter, points)
if self.LINK_ADDITIONNAL_POINTS and other_points:
pen2 = painter.pen()
pen2.setStyle(QC.Qt.DotLine)
painter.setPen(pen2)
painter.drawPolyline(other_points)
[docs]
def poly_hit_test(self, plot: BasePlot, ax: int, ay: int, pos: QPointF) -> tuple:
"""Return a tuple (distance, attach point, inside, other_object)
Args:
plot: Plot
ax: X axis index
ay: Y axis index
pos: Position
Returns:
Tuple with four elements (distance, attach point, inside, other_object).
"""
pos = QC.QPointF(pos)
dist = sys.maxsize
handle = -1
Cx, Cy = pos.x(), pos.y()
poly = QG.QPolygonF()
pts = self.points
for i in range(pts.shape[0]):
# Compute distance to the line segment in canvas coordinates
px = plot.transform(ax, pts[i, 0])
py = plot.transform(ay, pts[i, 1])
if i < pts.shape[0] - self.ADDITIONNAL_POINTS:
poly.append(QC.QPointF(px, py))
d = (Cx - px) ** 2 + (Cy - py) ** 2
if d < dist:
dist = d
handle = i
inside = poly.containsPoint(QC.QPointF(Cx, Cy), QC.Qt.OddEvenFill)
return math.sqrt(dist), handle, inside, None
[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
return self.poly_hit_test(self.plot(), self.xAxis(), self.yAxis(), pos)
[docs]
def add_local_point(self, pos: tuple[float, float]) -> int:
"""Add a point in canvas coordinates (local coordinates)
Args:
pos: Position
Returns:
Handle of the added point
"""
pt = canvas_to_axes(self, pos)
return self.add_point(pt)
[docs]
def add_point(self, pt: tuple[float, float]) -> int:
"""Add a point in axis coordinates
Args:
pt: Position
Returns:
Handle of the added point
"""
if self.points.size == 0:
self.points = np.array([pt])
return 0
N, _ = self.points.shape
self.points = np.resize(self.points, (N + 1, 2))
self.points[N, :] = pt
return N
[docs]
def del_point(self, handle: int) -> int:
"""Delete a point
Args:
handle: Handle
Returns:
Handle of the deleted point
"""
self.points = np.delete(self.points, handle, 0)
if handle < len(self.points):
return handle
else:
return self.points.shape[0] - 1
[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
"""
self.points[handle, :] = pos
[docs]
def move_shape(self, old_pos: QC.QPointF, new_pos: QC.QPointF) -> None:
"""Translate the shape such that old_pos becomes new_pos in axis coordinates
Args:
old_pos: Old position
new_pos: New position
"""
dx = new_pos[0] - old_pos[0]
dy = new_pos[1] - old_pos[1]
self.points += np.array([[dx, dy]])
[docs]
def update_item_parameters(self) -> None:
"""Update item parameters (dataset) from object properties"""
self.shapeparam.update_param(self)
[docs]
def get_item_parameters(self, itemparams: ItemParameters) -> None:
"""
Appends datasets to the list of DataSets describing the parameters
used to customize apearance of this item
Args:
itemparams: Item parameters
"""
self.update_item_parameters()
itemparams.add("ShapeParam", self, self.shapeparam)
[docs]
def set_item_parameters(self, itemparams: ItemParameters) -> None:
"""
Change the appearance of this item according
to the parameter set provided
Args:
itemparams: Item parameters
"""
update_dataset(self.shapeparam, itemparams.get("ShapeParam"), visible_only=True)
self.shapeparam.update_item(self)
assert_interfaces_valid(PolygonShape)