4

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()
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
JokerMartini
  • 5,674
  • 9
  • 83
  • 193

1 Answers1

4

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()
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
  • Thanks I'll give this a try and see how it goes. – JokerMartini Dec 28 '18 at 04:08
  • @ellyanesc so why would a value of -1 be returned frequently when clicking along the rounded ends of the path? How can i improve this so it returns a value and not -1 – JokerMartini Dec 28 '18 at 14:48
  • got it working. I was doing a double stroke on the path which was my fault. thats what caused the negative values. thank you for your help and explaining the solution. much appreciated – JokerMartini Dec 28 '18 at 15:02
  • @ellyanesc, so i kinda got it work. im updating the code right now do contain the full bit of code. the Percentage value returned appears to be incorrect at times. You may be able to understand by testing it yourself. – JokerMartini Dec 28 '18 at 15:46
  • @ellyanesc did you watch the video i posted on youtube? What are you thoughts on why it does not work as expected? – JokerMartini Dec 28 '18 at 15:52
  • Ok, I'm open to an improved approach or you see a better way of doing it. – JokerMartini Dec 28 '18 at 16:04
  • What does the function partial method do. I noticed the graph runs terribly slow when i click and drag a dot. – JokerMartini Dec 28 '18 at 17:49
  • i think removing the sorted method from the update and only using the sort when a new point is added. – JokerMartini Dec 28 '18 at 19:02
  • I know, i was just writing down what i was going to do in order to optimize. thank you for your help. – JokerMartini Dec 28 '18 at 19:06
  • I would like to add a requirement that users have to hold down D on the keyboard when clicking a line in order to add the Knot to the line. Where do I capture the key event in order to pass it to the click event that exists on the ConnectionItem object, not in the main graphics view? – JokerMartini Dec 29 '18 at 14:47