Search code examples
pythonpysideqgraphicsviewqgraphicssceneqpainterpath

Get Clicked Point Along QPainterPath


How can I get the percentage representing the point clicked along a QPainterPath. For example say I have a line, like the image below, and a user clicks on the QPainterPath, represented by the red dot. I would like to log what percentage the point falls along the path. In this case it would print 0.75 since the point is located around 75%.

These are the known variables:

# QPainterPath
path = QPainterPath()
path.moveTo( QPointF(10.00, -10.00) )
path.cubicTo(
    QPointF(114.19, -10.00),
    QPointF(145.80, -150.00),
    QPointF(250.00, -150.00)
)

# User Clicked Point
QPointF(187.00, -130.00)

enter image description here

Updated!

My goal is to give the user the ability to click on the path and insert a point. Below is the code i have so far. You'll see in the video that it appears to fail when adding points between points. simply click on the path to insert a point.

Video Link to Watch bug:

https://youtu.be/nlWyZUIa7II

import sys
from PySide.QtGui import *
from PySide.QtCore import *
import random, math


class MyGraphicsView(QGraphicsView):
    def __init__(self):
        super(MyGraphicsView, self).__init__()
        self.setDragMode(QGraphicsView.RubberBandDrag)
        self.setCacheMode(QGraphicsView.CacheBackground)
        self.setHorizontalScrollBarPolicy( Qt.ScrollBarAlwaysOff )
        self.setVerticalScrollBarPolicy( Qt.ScrollBarAlwaysOff )


    def mousePressEvent(self,  event):
        item = self.itemAt(event.pos())
        if event.button() == Qt.LeftButton and isinstance(item, ConnectionItem):
            percentage = self.percentageByPoint(item.shape(), self.mapToScene(event.pos()))
            item.addKnotByPercent(percentage)
            event.accept()
        elif event.button() == Qt.MiddleButton:
            super(MyGraphicsView, self).mousePressEvent(event)


    # connection methods
    def percentageByPoint(self, path, point, precision=0.5, width=3.0):
        percentage = -1.0
        if path.contains(point):
            t = 0.0
            d = []
            while t <=100.0: 
                d.append(QVector2D(point - path.pointAtPercent(t/100.0)).length())
                t += precision
            percentage = d.index(min(d))*precision
        return percentage


class MyGraphicsScene(QGraphicsScene):
    def __init__(self,  parent):
        super(MyGraphicsScene,  self).__init__()
        self.setBackgroundBrush(QBrush(QColor(50,50,50)))


class KnotItem(QGraphicsEllipseItem):
    def __init__(self, parent=None,):
        super(self.__class__, self).__init__(parent)
        self.setAcceptHoverEvents(True)
        self.setFlag(self.ItemSendsScenePositionChanges, True)
        self.setFlag(self.ItemIsSelectable, True) # false
        self.setFlag(self.ItemIsMovable, True) # false
        self.setRect(-6, -6, 12, 12)

    # Overrides
    def paint(self, painter, option, widget=None):
        painter.save()
        painter.setRenderHint(QPainter.Antialiasing)
        painter.setPen(QPen(QColor(30,30,30), 2, Qt.SolidLine))
        painter.setBrush(QBrush(QColor(255,30,30)))
        painter.drawEllipse(self.rect())    
        painter.restore()


    def itemChange(self, change, value):
        if change == self.ItemScenePositionHasChanged:
            if self.parentItem():
                self.parentItem().update()
        return super(self.__class__, self).itemChange(change, value)


    def boundingRect(self):
        rect = self.rect()
        rect.adjust(-1,-1,1,1)
        return rect


class ConnectionItem(QGraphicsPathItem):
    def __init__(self, startPoint, endPoint, parent=None):
        super(ConnectionItem,  self).__init__()
        self._hover = False
        self.setAcceptHoverEvents(True)
        self.setFlag( QGraphicsItem.ItemIsSelectable )
        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
        self.setZValue(-100)

        self.startPoint = startPoint
        self.endPoint = endPoint
        self.knots = []
        self.update()


    def getBezierPath(self, points=[], curving=1.0):
        # Calculate Bezier Line
        path = QPainterPath()
        curving = 1.0 # range 0-1

        if len(points) < 2:
            return path

        path.moveTo(points[0])

        for i in range(len(points)-1):
            startPoint = points[i]
            endPoint = points[i+1]

            # use distance as mult, closer the nodes less the bezier
            dist = math.hypot(endPoint.x() - startPoint.x(), endPoint.y() - startPoint.y())

            # multiply distance by 0.375 
            offset = dist * 0.375 * curving
            ctrlPt1 = startPoint + QPointF(offset,0);
            ctrlPt2 = endPoint + QPointF(-offset,0);

            # print startPoint, ctrlPt1, ctrlPt2, endPoint
            path.cubicTo(ctrlPt1, ctrlPt2, endPoint)

        return path


    def drawPath(self, pos=None):
        # Calculate Bezier Line
        points = [self.startPoint]
        for k in self.knots:
            points.append(k.scenePos())
        points.append(self.endPoint)
        path = self.getBezierPath(points)
        self.setPath(path)


    def update(self):
        super(self.__class__, self).update()
        self.drawPath()


    def paint(self, painter, option, widget):
        painter.setRenderHints( QPainter.Antialiasing | QPainter.SmoothPixmapTransform | QPainter.HighQualityAntialiasing, True )
        pen = QPen(QColor(170,170,170), 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
        if self.isSelected():
            pen.setColor(QColor(255, 255, 255))
        elif self.hover:
            pen.setColor(QColor(255, 30, 30))
        painter.setPen(pen)
        painter.drawPath(self.path())


    def shape(self):
        '''
        Description:
            This is super important for creating a more accurate path used for 
            collision detection by cursor.
        '''
        qp = QPainterPathStroker()
        qp.setWidth(15)
        qp.setCapStyle(Qt.SquareCap)
        return qp.createStroke(self.path())


    def hoverEnterEvent(self, event):
        self.hover = True
        self.update()
        super(self.__class__, self).hoverEnterEvent(event)


    def hoverLeaveEvent(self, event):
        self.hover = False
        self.update()
        super(self.__class__, self).hoverEnterEvent(event)


    def addKnot(self, pos=QPointF(0,0)):
        '''
        Description:
            Add not based on current location of cursor or inbetween points on path.
        '''
        knotItem = KnotItem(parent=self)
        knotItem.setPos(pos)
        self.knots.append(knotItem)
        self.update()


    def addKnotByPercent(self, percentage=0.0):
        '''
        Description:
            The percentage value should be between 0.0 and 100.0. This value
            determines the location of the point and it's index in the knots list.
        '''
        if percentage < 0.0 or percentage > 100.0:
            return

        # add item
        pos = self.shape().pointAtPercent(percentage*.01)
        knotItem = KnotItem(parent=self)
        knotItem.setPos(pos)

        index = int(len(self.knots) * (percentage*.01))
        print len(self.knots), (percentage), index
        self.knots.insert(index, knotItem)
        self.update()


    # properties
    @property
    def hover(self):
        return self._hover

    @hover.setter
    def hover(self, value=False):
        self._hover = value
        self.update()


class MyMainWindow(QMainWindow):

    def __init__(self):
        super(MyMainWindow, self).__init__()
        self.setWindowTitle("Test")
        self.resize(800,600)

        self.gv = MyGraphicsView()
        self.gv.setScene(MyGraphicsScene(self))
        self.btnReset = QPushButton('Reset')

        lay_main = QVBoxLayout()
        lay_main.addWidget(self.btnReset)
        lay_main.addWidget(self.gv)
        widget_main = QWidget()
        widget_main.setLayout(lay_main)
        self.setCentralWidget(widget_main)

        self.populate()

        # connect
        self.btnReset.clicked.connect(self.populate)


    def populate(self):
        scene = self.gv.scene()
        for x in scene.items():
            scene.removeItem(x)
            del x

        con = ConnectionItem(QPointF(-150,150), QPointF(250,-150))
        scene.addItem(con)


def main():
    app = QApplication(sys.argv)
    ex = MyMainWindow()
    ex.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()

Solution

  • A possible solution is to use pointAtPercent () that returns a given point a percentage and calculate the distance to the point and find the minimum index and multiply it by the step. But for this the search must be refined because the previous algorithm works for any point even if it is outside the path. The idea in this case is to use a QPainterPath with a certain area using QPainterPathStroker and verify if the point belongs, and if not the value is outside the QPainterPath.

    C++

    #include <QtGui>
    
    static qreal percentageByPoint(const QPainterPath & path, const QPointF & p, qreal precision=0.5, qreal width=3.0){
        qreal percentage = -1;
        QPainterPathStroker stroker;
        stroker.setWidth(width);
        QPainterPath strokepath = stroker.createStroke(path);
        if(strokepath.contains(p)){
            std::vector<qreal> d;
            qreal t=0.0;
            while(t<=100.0){
                d.push_back(QVector2D(p - path.pointAtPercent(t/100)).length());
                t+= precision;
            }
            std::vector<qreal>::iterator result = std::min_element(d.begin(), d.end());
            int j= std::distance(d.begin(), result);
            percentage = j*precision;
        }
        return percentage;
    }
    
    int main(int argc, char *argv[])
    {
        Q_UNUSED(argc)
        Q_UNUSED(argv)
    
        QPainterPath path;
        path.moveTo( QPointF(10.00, -10.00) );
        path.cubicTo(
                    QPointF(114.19, -10.00),
                    QPointF(145.80, -150.00),
                    QPointF(250.00, -150.00)
                    );
    
        // User Clicked Point
        QPointF p(187.00, -130.00);
        qreal percentage = percentageByPoint(path, p);
        qDebug() << percentage;
    
        return 0;
    }
    

    python:

    def percentageByPoint(path, point, precision=0.5, width=3.0):
        percentage = -1.0
        stroker = QtGui.QPainterPathStroker()
        stroker.setWidth(width)
        strokepath = stroker.createStroke(path) 
        if strokepath.contains(point):
            t = 0.0
            d = []
            while t <=100.0: 
                d.append(QtGui.QVector2D(point - path.pointAtPercent(t/100)).length())
                t += precision
            percentage = d.index(min(d))*precision
        return percentage
    
    if __name__ == '__main__':
        path = QtGui.QPainterPath()
        path.moveTo(QtCore.QPointF(10.00, -10.00) )
        path.cubicTo(
            QtCore.QPointF(114.19, -10.00),
            QtCore.QPointF(145.80, -150.00),
            QtCore.QPointF(250.00, -150.00)
            )
    
        point = QtCore.QPointF(187.00, -130.00)
        percentage = percentageByPoint(path, point)
        print(percentage)
    

    Output:

    76.5
    

    Instead of implementing the logic in QGraphicsView, you must do it in the item, and then when you update the path, the points must be ordered with respect to the percentage.

    import math
    from PySide import QtCore, QtGui
    from functools import partial
    
    class MyGraphicsView(QtGui.QGraphicsView):
        def __init__(self):
            super(MyGraphicsView, self).__init__()
            self.setDragMode(QtGui.QGraphicsView.RubberBandDrag)
            self.setCacheMode(QtGui.QGraphicsView.CacheBackground)
            self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            scene = QtGui.QGraphicsScene(self)
            scene.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(50,50,50)))
            self.setScene(scene)
    
    class KnotItem(QtGui.QGraphicsEllipseItem):
        def __init__(self, parent=None,):
            super(self.__class__, self).__init__(parent)
            self.setAcceptHoverEvents(True)
            self.setFlag(self.ItemSendsScenePositionChanges, True)
            self.setFlag(self.ItemIsSelectable, True)
            self.setFlag(self.ItemIsMovable, True) 
            self.setRect(-6, -6, 12, 12)
            self.setPen(QtGui.QPen(QtGui.QColor(30,30,30), 2, QtCore.Qt.SolidLine))
            self.setBrush(QtGui.QBrush(QtGui.QColor(255,30,30)))
    
        def itemChange(self, change, value):
            if change == self.ItemScenePositionHasChanged:
                if isinstance(self.parentItem(), ConnectionItem):
                    self.parentItem().updatePath()
                    # QtCore.QTimer.singleShot(60, partial(self.parentItem().setSelected,False))
            return super(self.__class__, self).itemChange(change, value)
    
    
    class ConnectionItem(QtGui.QGraphicsPathItem):
        def __init__(self, startPoint, endPoint, parent=None):
            super(ConnectionItem, self).__init__(parent)
            self._start_point = startPoint
            self._end_point = endPoint
    
            self._hover = False
            self.setAcceptHoverEvents(True)
            self.setFlag(QtGui.QGraphicsItem.ItemIsSelectable )
            self.setFlag(QtGui.QGraphicsItem.ItemSendsScenePositionChanges)
            self.setZValue(-100)
            self.updatePath()
    
        def updatePath(self):
            p = [self._start_point]
            for children in self.childItems():
                if isinstance(children, KnotItem):
                    p.append(children.pos())
            p.append(self._end_point)
            v = sorted(p, key=partial(ConnectionItem.percentageByPoint, self.path()))
            self.setPath(ConnectionItem.getBezierPath(v))
    
        def paint(self, painter, option, widget):
            painter.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform | QtGui.QPainter.HighQualityAntialiasing, True )
            pen = QtGui.QPen(QtGui.QColor(170,170,170), 2, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin)
            if self.isSelected():
                pen.setColor(QtGui.QColor(255, 255, 255))
            elif self._hover:
                pen.setColor(QtGui.QColor(255, 30, 30))
            painter.setPen(pen)
            painter.drawPath(self.path())
    
        def mousePressEvent(self, event):
            if event.button() == QtCore.Qt.LeftButton:
                item = KnotItem(parent=self)
                item.setPos(event.pos())
    
        def hoverEnterEvent(self, event):
            self._hover = True
            self.update()
            super(self.__class__, self).hoverEnterEvent(event)
    
        def hoverLeaveEvent(self, event):
            self._hover = False
            self.update()
            super(self.__class__, self).hoverEnterEvent(event)
    
        def shape(self):
            qp = QtGui.QPainterPathStroker()
            qp.setWidth(15)
            qp.setCapStyle(QtCore.Qt.SquareCap)
            return qp.createStroke(self.path())
    
        @staticmethod
        def getBezierPath(points=[], curving=1.0):
            # Calculate Bezier Line
            path = QtGui.QPainterPath()
            curving = 1.0 # range 0-1
            if len(points) < 2:
                return path
            path.moveTo(points[0])
            for i in range(len(points)-1):
                startPoint = points[i]
                endPoint = points[i+1]
                # use distance as mult, closer the nodes less the bezier
                dist = math.hypot(endPoint.x() - startPoint.x(), endPoint.y() - startPoint.y())
                # multiply distance by 0.375 
                offset = dist * 0.375 * curving
                ctrlPt1 = startPoint + QtCore.QPointF(offset,0);
                ctrlPt2 = endPoint + QtCore.QPointF(-offset,0);
                # print startPoint, ctrlPt1, ctrlPt2, endPoint
                path.cubicTo(ctrlPt1, ctrlPt2, endPoint)
            return path
    
        @staticmethod
        def percentageByPoint(path, point, precision=0.5):
            t = 0.0
            d = []
            while t <=100.0: 
                d.append(QtGui.QVector2D(point - path.pointAtPercent(t/100.0)).length())
                t += precision
                percentage = d.index(min(d))*precision
            return percentage
    
    
    class MyMainWindow(QtGui.QMainWindow):
        def __init__(self):
            super(MyMainWindow, self).__init__()
            central_widget = QtGui.QWidget()
            self.setCentralWidget(central_widget)
            button = QtGui.QPushButton("Reset")
            self._view = MyGraphicsView()
            button.clicked.connect(self.reset)
    
            lay = QtGui.QVBoxLayout(central_widget)
            lay.addWidget(button)
            lay.addWidget(self._view)
            self.resize(640, 480)
            self.reset()
    
        @QtCore.Slot()
        def reset(self):
            self._view.scene().clear()
            it = ConnectionItem(QtCore.QPointF(-150,150), QtCore.QPointF(250,-150))
            self._view.scene().addItem(it)
    
    def main():
        import sys
        app =QtGui.QApplication(sys.argv)
        ex = MyMainWindow()
        ex.show()
        sys.exit(app.exec_())
    
    main()