Implementing one dimensional drag operation in Qt - python

I want to allow a QGraphicsItem to be dragged only in certain directions, such as +/-45 degrees, horizontally or vertically, and to be able to "jump" to a new direction once the cursor is dragged far enough away from the current closest direction. This would replicate behaviour in e.g. Inkscape when drawing a straight line and holding Ctrl (see e.g. this video), but I am unsure how to implement it.
I've implemented a drag handler that grabs the new position of the item as it is moved:
class Circle(QGraphicsEllipseItem):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Flags to allow dragging and tracking of dragging.
self.setFlag(self.ItemSendsGeometryChanges)
self.setFlag(self.ItemIsMovable)
self.setFlag(self.ItemIsSelectable)
def itemChange(self, change, value):
if change == self.ItemPositionChange and self.isSelected():
# do something...
# Return the new position to parent to have this item move there.
return super().itemChange(change, value)
Since the position returned to the parent by this method is used to update the position of the item in the scene, I expect that I can modify this QPointF to limit it to one axis, but I am unsure how to do so in a way that lets the line "jump" to another direction once the cursor is dragged far enough. Are there any "standard algorithms" for this sort of behaviour? Or perhaps some built-in Qt code that can do this for me?

The problem is reduced to calculate the projection of the point (position of the item) on the line. Doing a little math as explained in this post.
Let p1 and p2 be two different points on the line and p the point then the algorithm is:
e1 = p2 - p1
e2 = p - p1
dp = e1 • e2 # dot product
l = e1 • e1 # dot product
pp = p1 + dp * e1 / l
Implementing the above the solution is:
import math
import random
from PyQt5 import QtCore, QtGui, QtWidgets
class Circle(QtWidgets.QGraphicsEllipseItem):
def __init__(self, *args, **kwargs):
self._line = QtCore.QLineF()
super().__init__(*args, **kwargs)
# Flags to allow dragging and tracking of dragging.
self.setFlags(
self.flags()
| QtWidgets.QGraphicsItem.ItemSendsGeometryChanges
| QtWidgets.QGraphicsItem.ItemIsMovable
| QtWidgets.QGraphicsItem.ItemIsSelectable
)
#property
def line(self):
return self._line
#line.setter
def line(self, line):
self._line = line
def itemChange(self, change, value):
if (
change == QtWidgets.QGraphicsItem.ItemPositionChange
and self.isSelected()
and not self.line.isNull()
):
# http://www.sunshine2k.de/coding/java/PointOnLine/PointOnLine.html
p1 = self.line.p1()
p2 = self.line.p2()
e1 = p2 - p1
e2 = value - p1
dp = QtCore.QPointF.dotProduct(e1, e2)
l = QtCore.QPointF.dotProduct(e1, e1)
p = p1 + dp * e1 / l
return p
return super().itemChange(change, value)
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
scene = QtWidgets.QGraphicsScene(QtCore.QRectF(-200, -200, 400, 400))
view = QtWidgets.QGraphicsView(scene)
points = (
QtCore.QPointF(*random.sample(range(-150, 150), 2)) for _ in range(4)
)
angles = (math.pi / 4, math.pi / 3, math.pi / 5, math.pi / 2)
for point, angle in zip(points, angles):
item = Circle(QtCore.QRectF(-10, -10, 20, 20))
item.setBrush(QtGui.QColor("salmon"))
scene.addItem(item)
item.setPos(point)
end = 100 * QtCore.QPointF(math.cos(angle), math.sin(angle))
line = QtCore.QLineF(QtCore.QPointF(), end)
item.line = line.translated(item.pos())
line_item = scene.addLine(item.line)
line_item.setPen(QtGui.QPen(QtGui.QColor("green"), 4))
view.resize(640, 480)
view.show()
sys.exit(app.exec_())

Related

Add Mouse Motion functionality to PyQt based Bezier Drawer

I am trying to modify my code below to take-in mouse events (click, drag, release) such that the control points can be selected and moved, resulting in change in the curve. I am not sure where to begin, any suggestions? The control points are marked as red dots, the curve is in blue.
This would basically let me modify the curve within the gui. Any reference would be appreciated as well.
import sys
import random
import functools
from PyQt5 import QtWidgets, QtGui, QtCore
#functools.lru_cache(maxsize=100)
def factorial(n):
prod = 1
for i in range(1,n+1):
prod *= i
return prod
def randPt(minv, maxv):
return (random.randint(minv, maxv), random.randint(minv, maxv))
def B(i,n,u):
val = factorial(n)/(factorial(i)*factorial(n-i))
return val * (u**i) * ((1-u)**(n-i))
def C(u, pts):
x = 0
y = 0
n = len(pts)-1
for i in range(n+1):
binu = B(i,n,u)
x += binu * pts[i][0]
y += binu * pts[i][1]
return (x, y)
class BezierDrawer(QtWidgets.QWidget):
def __init__(self):
super(BezierDrawer, self).__init__()
self.setGeometry(300, 300, 1500,1000)
self.setWindowTitle('Bezier Curves')
def paintEvent(self, e):
qp = QtGui.QPainter()
qp.begin(self)
qp.setRenderHints(QtGui.QPainter.Antialiasing, True)
self.doDrawing(qp)
qp.end()
def doDrawing(self, qp):
blackPen = QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine)
redPen = QtGui.QPen(QtCore.Qt.red, 30, QtCore.Qt.DashLine)
bluePen = QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine)
greenPen = QtGui.QPen(QtCore.Qt.green, 3, QtCore.Qt.DashLine)
redBrush = QtGui.QBrush(QtCore.Qt.red)
steps = 400
min_t = 0.0
max_t = 1.0
dt = (max_t - min_t)/steps
# controlPts = [randPt(0,1000) for i in range(6)]
# controlPts.append(controlPts[1])
# controlPts.append(controlPts[0])
controlPts = [(500,500), (600,700), (600,550), (700,500),(700,500), (800,400), (1000,200), (1000,500)]
oldPt = controlPts[0]
pn = 1
qp.setPen(redPen)
qp.setBrush(redBrush)
qp.drawEllipse(oldPt[0]-3, oldPt[1]-3, 6,6)
#qp.drawText(oldPt[0]+5, oldPt[1]-3, '{}'.format(pn))
for pt in controlPts[1:]:
pn+=1
qp.setPen(blackPen)
qp.drawLine(oldPt[0],oldPt[1],pt[0],pt[1])
qp.setPen(redPen)
qp.drawEllipse(pt[0]-3, pt[1]-3, 6,6)
#xv=qp.drawText(pt[0]+5, pt[1]-3, '{}'.format(pn))
#xv.setTextWidth(3)
oldPt = pt
qp.setPen(bluePen)
oldPt = controlPts[0]
for i in range(steps+1):
t = dt*i
pt = C(t, controlPts)
qp.drawLine(oldPt[0],oldPt[1], pt[0],pt[1])
oldPt = pt
def main(args):
app = QtWidgets.QApplication(sys.argv)
ex = BezierDrawer()
ex.show()
app.exec_()
if __name__=='__main__':
main(sys.argv[1:])
Premise note: while I normally don't answer such questions with a complete new implementation, the subject of higher-order Bézier curves is quite interesting and not directly available in Qt (and in any general toolkit), so I'm breaking my general rule to provide an expanded answer, as I believe it could be useful to others.
While the accepted answer properly addresses the issue, as stated, using the Graphics View Framework is almost always a better choice, as it provides more modularity, advanced interaction, optimization and features that would be difficult to implement in a standard widget (notably, transformations like scaling and rotation).
Before explaining the implementation, some important notes about the original code:
the factorial() function is slow, and its caching is almost useless, since it will probably never be able to use any cached data due to the amount and diversity of results (there are 400 hardcoded steps, but the cache limit is set to 100); the math module already provides factorial(), which is much faster since it's purely implemented on the C side;
the B() function only executes a computation, so it is almost useless; the same goes for C(): while creating functions may be important for readability and code separation, in this case their usability and usage makes them pointless;
if you want a continuous dashed line for the curve, you cannot use drawLine(), as it will draw distinct segments: since those segments will almost always be very short, no dashed line will be ever shown; use QPainterPath instead;
such extensive and repetitive computation should never be executed in the paintEvent() (which can be called very frequently) and some level of caching should be preferably used; for instance, you can create an empty instance attribute (eg. self.cachePath = None), check if the attribute is empty in the paintEvent() and eventually call a function that creates the path in that case; also consider QPicture;
(unrelated, but still important) calling main with sys.argv[1:] is a bit pointless if you still create the QApplication with the full sys.argv;
In the following implementation I created a main BezierItem as subclass of QGraphicsPathItem which will contain the complete curve and embeds a child QGraphicsPathItem for the line segments and a variable number of ControlPoint objects, which are QGraphicsObject subclasses. The QObject inheritance is to add signal support, which is required to notify about item position changes (it could be technically avoided by using a basic QGraphicsItem and calling the function of the parent, but that wouldn't be very elegant).
Note that, since the curve can potentially call the point computation thousands of times, optimization is of utmost importance: consider that for a "simple" 8th-order curve with a mere 20-step-per-point precision, the result is that the inner computation will be executed about 1200 times. With 20 points and a 40-step precision, the result is more than 16000 cycles.
The BezierItem also contains a list of control points as QPointFs for optimization reasons, and it uses two important functions: updatePath(), which updates the segments that connect each control point, and then calls _rebuildPath() which actually creates the final curve as a QPainterPath. The distinction is important as you may need to rebuild the path when the resolution changes, but the points are still the same.
Note that you used a peculiar way to show control points (a small ellipse with a very thick pen width, which results in a fancy rounded octagon due to the way Qt draw shapes). This can cause some issues in mouse detection, as shaped QGraphicsItems use their internal path and pen to build the shape for collision detection (including mouse events). To avoid these issues, I created a simplified() path based on that shape that will improve performance and ensure proper behavior.
import random
from math import factorial
from PyQt5 import QtWidgets, QtGui, QtCore
class ControlPoint(QtWidgets.QGraphicsObject):
moved = QtCore.pyqtSignal(int, QtCore.QPointF)
removeRequest = QtCore.pyqtSignal(object)
brush = QtGui.QBrush(QtCore.Qt.red)
# create a basic, simplified shape for the class
_base = QtGui.QPainterPath()
_base.addEllipse(-3, -3, 6, 6)
_stroker = QtGui.QPainterPathStroker()
_stroker.setWidth(30)
_stroker.setDashPattern(QtCore.Qt.DashLine)
_shape = _stroker.createStroke(_base).simplified()
# "cache" the boundingRect for optimization
_boundingRect = _shape.boundingRect()
def __init__(self, index, pos, parent):
super().__init__(parent)
self.index = index
self.setPos(pos)
self.setFlags(
self.ItemIsSelectable
| self.ItemIsMovable
| self.ItemSendsGeometryChanges
| self.ItemStacksBehindParent
)
self.setZValue(-1)
self.setToolTip(str(index + 1))
self.font = QtGui.QFont()
self.font.setBold(True)
def setIndex(self, index):
self.index = index
self.setToolTip(str(index + 1))
self.update()
def shape(self):
return self._shape
def boundingRect(self):
return self._boundingRect
def itemChange(self, change, value):
if change == self.ItemPositionHasChanged:
self.moved.emit(self.index, value)
elif change == self.ItemSelectedHasChanged and value:
# stack this item above other siblings when selected
for other in self.parentItem().childItems():
if isinstance(other, self.__class__):
other.stackBefore(self)
return super().itemChange(change, value)
def contextMenuEvent(self, event):
menu = QtWidgets.QMenu()
removeAction = menu.addAction(QtGui.QIcon.fromTheme('edit-delete'), 'Delete point')
if menu.exec(QtGui.QCursor.pos()) == removeAction:
self.removeRequest.emit(self)
def paint(self, qp, option, widget=None):
qp.setBrush(self.brush)
if not self.isSelected():
qp.setPen(QtCore.Qt.NoPen)
qp.drawPath(self._shape)
qp.setPen(QtCore.Qt.white)
qp.setFont(self.font)
r = QtCore.QRectF(self.boundingRect())
r.setSize(r.size() * 2 / 3)
qp.drawText(r, QtCore.Qt.AlignCenter, str(self.index + 1))
class BezierItem(QtWidgets.QGraphicsPathItem):
_precision = .05
_delayUpdatePath = False
_ctrlPrototype = ControlPoint
def __init__(self, points=None):
super().__init__()
self.setPen(QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine))
self.outlineItem = QtWidgets.QGraphicsPathItem(self)
self.outlineItem.setFlag(self.ItemStacksBehindParent)
self.outlineItem.setPen(QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine))
self.controlItems = []
self._points = []
if points is not None:
self.setPoints(points)
def setPoints(self, pointList):
points = []
for p in pointList:
if isinstance(p, (QtCore.QPointF, QtCore.QPoint)):
# always create a copy of each point!
points.append(QtCore.QPointF(p))
else:
points.append(QtCore.QPointF(*p))
if points == self._points:
return
self._points = []
self.prepareGeometryChange()
while self.controlItems:
item = self.controlItems.pop()
item.setParentItem(None)
if self.scene():
self.scene().removeItem(item)
del item
self._delayUpdatePath = True
for i, p in enumerate(points):
self.insertControlPoint(i, p)
self._delayUpdatePath = False
self.updatePath()
def _createControlPoint(self, index, pos):
ctrlItem = self._ctrlPrototype(index, pos, self)
self.controlItems.insert(index, ctrlItem)
ctrlItem.moved.connect(self._controlPointMoved)
ctrlItem.removeRequest.connect(self.removeControlPoint)
def addControlPoint(self, pos):
self.insertControlPoint(-1, pos)
def insertControlPoint(self, index, pos):
if index < 0:
index = len(self._points)
for other in self.controlItems[index:]:
other.index += 1
other.update()
self._points.insert(index, pos)
self._createControlPoint(index, pos)
if not self._delayUpdatePath:
self.updatePath()
def removeControlPoint(self, cp):
if isinstance(cp, int):
index = cp
else:
index = self.controlItems.index(cp)
item = self.controlItems.pop(index)
self.scene().removeItem(item)
item.setParentItem(None)
for other in self.controlItems[index:]:
other.index -= 1
other.update()
del item, self._points[index]
self.updatePath()
def precision(self):
return self._precision
def setPrecision(self, precision):
precision = max(.001, min(.5, precision))
if self._precision != precision:
self._precision = precision
self._rebuildPath()
def stepRatio(self):
return int(1 / self._precision)
def setStepRatio(self, ratio):
'''
Set the *approximate* number of steps per control point. Note that
the step count is adjusted to an integer ratio based on the number
of control points.
'''
self.setPrecision(1 / ratio)
self.update()
def updatePath(self):
outlinePath = QtGui.QPainterPath()
if self.controlItems:
outlinePath.moveTo(self._points[0])
for point in self._points[1:]:
outlinePath.lineTo(point)
self.outlineItem.setPath(outlinePath)
self._rebuildPath()
def _controlPointMoved(self, index, pos):
self._points[index] = pos
self.updatePath()
def _rebuildPath(self):
'''
Actually rebuild the path based on the control points and the selected
curve precision. The default (0.05, ~20 steps per control point) is
usually enough, lower values result in higher resolution but slower
performance, and viceversa.
'''
self.curvePath = QtGui.QPainterPath()
if self._points:
self.curvePath.moveTo(self._points[0])
count = len(self._points)
steps = round(count / self._precision)
precision = 1 / steps
n = count - 1
# we're going to iterate through points *a lot* of times; with the
# small cost of a tuple, we can cache the inner iterator to speed
# things up a bit, instead of creating it in each for loop cycle
pointIterator = tuple(enumerate(self._points))
for s in range(steps + 1):
u = precision * s
x = y = 0
for i, point in pointIterator:
binu = (factorial(n) / (factorial(i) * factorial(n - i))
* (u ** i) * ((1 - u) ** (n - i)))
x += binu * point.x()
y += binu * point.y()
self.curvePath.lineTo(x, y)
self.setPath(self.curvePath)
class BezierExample(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.bezierScene = QtWidgets.QGraphicsScene()
self.bezierView = QtWidgets.QGraphicsView(self.bezierScene)
self.bezierView.setRenderHints(QtGui.QPainter.Antialiasing)
self.bezierItem = BezierItem([
(500, 500), (600, 700), (600, 550), (700, 500),
(700, 500), (800, 400), (1000, 200), (1000, 500)
])
self.bezierScene.addItem(self.bezierItem)
mainLayout = QtWidgets.QVBoxLayout(self)
topLayout = QtWidgets.QHBoxLayout()
mainLayout.addLayout(topLayout)
topLayout.addWidget(QtWidgets.QLabel('Resolution:'))
resSpin = QtWidgets.QSpinBox(minimum=1, maximum=100)
resSpin.setValue(self.bezierItem.stepRatio())
topLayout.addWidget(resSpin)
topLayout.addStretch()
addButton = QtWidgets.QPushButton('Add point')
topLayout.addWidget(addButton)
mainLayout.addWidget(self.bezierView)
self.bezierView.installEventFilter(self)
resSpin.valueChanged.connect(self.bezierItem.setStepRatio)
addButton.clicked.connect(self.addPoint)
def addPoint(self, point=None):
if not isinstance(point, (QtCore.QPoint, QtCore.QPointF)):
point = QtCore.QPointF(
random.randrange(int(self.bezierScene.sceneRect().width())),
random.randrange(int(self.bezierScene.sceneRect().height())))
self.bezierItem.addControlPoint(point)
def eventFilter(self, obj, event):
if event.type() == event.MouseButtonDblClick:
pos = self.bezierView.mapToScene(event.pos())
self.addPoint(pos)
return True
return super().eventFilter(obj, event)
def sizeHint(self):
return QtWidgets.QApplication.primaryScreen().size() * 2 / 3
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
ex = BezierExample()
ex.show()
app.exec_()
I will probably change the above code in the future, mostly to provide a valid backend for Nth-order curves without control point interaction and eventually a further subclass to add that support.
But, right now, the requested support is complete, and I strongly urge you to carefully take your time in studying every part of that code. The Graphics View framework is as powerful as it's complex to understand, and it may take weeks (at least!) to really get it.
As always, the basic rule remains: study the documentation. And, possibly, the source code.
As it was suggested, for more "serious" application, You should go for GraphicsView.
Your app was however almost done with simple drawing. It's not a big deal to to modify it to work as You want.
I made few changes to Your code. You have to make list of control points as a attribute of Your BezierDrawer. Then You can use events: mousePressEvent, mouseMoveEvent and mouseReleaseEvent to interact with control points.
First we need to find out, if any point was clicked in mousePressEvent and then just update it's position while dragging. Every change of point position must end with update method, to repaint widget.
Here is modified code:
import functools
import random
import sys
from PyQt5 import QtWidgets, QtGui, QtCore
#functools.lru_cache(maxsize=100)
def factorial(n):
prod = 1
for i in range(1, n + 1):
prod *= i
return prod
def randPt(minv, maxv):
return (random.randint(minv, maxv), random.randint(minv, maxv))
def B(i, n, u):
val = factorial(n) / (factorial(i) * factorial(n - i))
return val * (u ** i) * ((1 - u) ** (n - i))
def C(u, pts):
x = 0
y = 0
n = len(pts) - 1
for i in range(n + 1):
binu = B(i, n, u)
x += binu * pts[i][0]
y += binu * pts[i][1]
return (x, y)
class BezierDrawer(QtWidgets.QWidget):
def __init__(self):
super(BezierDrawer, self).__init__()
# Dragged point index
self.dragged_point = None
# List of control points
self.controlPts = [(500, 500), (600, 700), (600, 550), (700, 500), (800, 400), (1000, 200),
(1000, 500)]
self.setGeometry(300, 300, 1500, 1000)
self.setWindowTitle('Bezier Curves')
def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
"""Get through all control points and find out if mouse clicked on it"""
for control_point, (x, y) in enumerate(self.controlPts):
if a0.x() - 15 <= x <= a0.x() + 15 and a0.y() - 15 <= y <= a0.y() + 15:
self.dragged_point = control_point
return
def mouseMoveEvent(self, a0: QtGui.QMouseEvent) -> None:
"""If any point is dragged, change its position and repaint scene"""
if self.dragged_point is not None:
self.controlPts[self.dragged_point] = (a0.x(), a0.y())
self.update()
def mouseReleaseEvent(self, a0: QtGui.QMouseEvent) -> None:
"""Release dragging point and repaint scene again"""
self.dragged_point = None
self.update()
def paintEvent(self, e):
qp = QtGui.QPainter()
qp.begin(self)
qp.setRenderHints(QtGui.QPainter.Antialiasing, True)
self.doDrawing(qp)
qp.end()
def doDrawing(self, qp):
blackPen = QtGui.QPen(QtCore.Qt.black, 3, QtCore.Qt.DashLine)
redPen = QtGui.QPen(QtCore.Qt.red, 30, QtCore.Qt.DashLine)
bluePen = QtGui.QPen(QtCore.Qt.blue, 3, QtCore.Qt.DashLine)
greenPen = QtGui.QPen(QtCore.Qt.green, 3, QtCore.Qt.DashLine)
redBrush = QtGui.QBrush(QtCore.Qt.red)
steps = 400
min_t = 0.0
max_t = 1.0
dt = (max_t - min_t) / steps
oldPt = self.controlPts[0]
pn = 1
qp.setPen(redPen)
qp.setBrush(redBrush)
qp.drawEllipse(oldPt[0] - 3, oldPt[1] - 3, 6, 6)
for pt in self.controlPts[1:]:
pn += 1
qp.setPen(blackPen)
qp.drawLine(oldPt[0], oldPt[1], pt[0], pt[1])
qp.setPen(redPen)
qp.drawEllipse(pt[0] - 3, pt[1] - 3, 6, 6)
oldPt = pt
qp.setPen(bluePen)
oldPt = self.controlPts[0]
for i in range(steps + 1):
t = dt * i
pt = C(t, self.controlPts)
qp.drawLine(oldPt[0], oldPt[1], pt[0], pt[1])
oldPt = pt
def main(args):
app = QtWidgets.QApplication(sys.argv)
ex = BezierDrawer()
ex.show()
app.exec_()
if __name__ == '__main__':
main(sys.argv[1:])

Avoiding collisions of QGraphicsItem shapes moved by the mouse

An interesting discussion was raised here about preventing collisions of circles, made of QGraphicsEllipseItems, in a QGraphicsScene. The question narrowed the scope to 2 colliding items but the larger goal still remained, what about for any number of collisions?
This is the desired behavior:
When one item is dragged over other items they should not overlap, instead it should move around those items as close as possible to the mouse.
It should not “teleport” if it gets blocked in by other items.
It should be a smooth and predictable movement.
As this becomes increasingly complex to find the best “safe” position for the circle while it’s moving I wanted to present another way to implement this using a physics simulator.
Given the behavior described above it’s a good candidate for 2D rigid body physics, maybe it can be done without but it would be difficult to get it perfect. I am using pymunk in this example because I’m familiar with it but the same concepts will work with other libraries.
The scene has a kinematic body to represent the mouse and the circles are represented by static bodies initially. While a circle is selected it switches to a dynamic body and is constrained to the mouse by a damped spring. Its position is updated as the space is updated by a given time step on each timeout interval.
The item is not actually moved in the same way as the ItemIsMovable flag is not enabled, which means it no longer moves instantly with the mouse. It’s very close but there’s a small delay, although you may prefer this to better see how it reacts to collisions. (Even so, you can fine-tune the parameters to have it move faster/closer to the mouse than I did**).
On the other hand, the collisions are handled perfectly and will already support other kinds of shapes.
import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import pymunk
class Circle(QGraphicsEllipseItem):
def __init__(self, r, **kwargs):
super().__init__(-r, -r, r * 2, r * 2, **kwargs)
self.setFlag(QGraphicsItem.ItemIsSelectable)
self.static = pymunk.Body(body_type=pymunk.Body.STATIC)
self.circle = pymunk.Circle(self.static, r)
self.circle.friction = 0
mass = 10
self.dynamic = pymunk.Body(mass, pymunk.moment_for_circle(mass, 0, r))
self.updatePos = lambda: self.setPos(*self.dynamic.position, dset=False)
def setPos(self, *pos, dset=True):
super().setPos(*pos)
if len(pos) == 1:
pos = pos[0].x(), pos[0].y()
self.static.position = pos
if dset:
self.dynamic.position = pos
def itemChange(self, change, value):
if change == QGraphicsItem.ItemSelectedChange:
space = self.circle.space
space.remove(self.circle.body, self.circle)
self.circle.body = self.dynamic if value else self.static
space.add(self.circle.body, self.circle)
return super().itemChange(change, value)
def paint(self, painter, option, widget):
option.state &= ~QStyle.State_Selected
super().paint(painter, option, widget)
class Scene(QGraphicsScene):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.space = pymunk.Space()
self.space.damping = 0.02
self.body = pymunk.Body(body_type=pymunk.Body.KINEMATIC)
self.space.add(self.body)
self.timer = QTimer(self, timerType=Qt.PreciseTimer, timeout=self.step)
self.selectionChanged.connect(self.setConstraint)
def setConstraint(self):
selected = self.selectedItems()
if selected:
shape = selected[0].circle
if not shape.body.constraints:
self.space.remove(*self.space.constraints)
spring = pymunk.DampedSpring(
self.body, shape.body, (0, 0), (0, 0),
rest_length=0, stiffness=100, damping=10)
spring.collide_bodies = False
self.space.add(spring)
def step(self):
for i in range(10):
self.space.step(1 / 30)
self.selectedItems()[0].updatePos()
def mousePressEvent(self, event):
super().mousePressEvent(event)
if self.selectedItems():
self.body.position = event.scenePos().x(), event.scenePos().y()
self.timer.start(1000 / 30)
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
if self.selectedItems():
self.body.position = event.scenePos().x(), event.scenePos().y()
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self.timer.stop()
def addCircle(self, x, y, radius):
item = Circle(radius)
item.setPos(x, y)
self.addItem(item)
self.space.add(item.circle.body, item.circle)
return item
if __name__ == '__main__':
app = QApplication(sys.argv)
scene = Scene(0, 0, 1000, 800)
for i in range(7, 13):
item = scene.addCircle(150 * (i - 6), 400, i * 5)
item.setBrush(Qt.GlobalColor(i))
view = QGraphicsView(scene, renderHints=QPainter.Antialiasing)
view.show()
sys.exit(app.exec_())
**Can adjust the following:
Spring stiffness and damping
Body mass and moment of inertia
Space damping
Space.step time step / how many calls per QTimer timeout
QTimer interval

How to show markings on QDial in PyQt5 Python

I am designing a ui in qt-desginer where I have added dial. It looks like below:
It doesn't have any markings. Is it possible to add some markings to understand it better. Something like below:
Unfortunately, QDial is a widget that has never been really cared about, mostly because it's scarcely used, but also because many feature needs might change its behavior in inconsistent ways. In any case, this is not possible from Designer.
It can be partially done by subclassing QDial and promoting it in designer, but you'll end up having numbers drawn over the notches or the dial itself, which will look ugly.
In order to achieve what you want, you need to use a custom widget that contains the dial, and eventually promote that widget, but you'll not be able to see it in designer nor set its properties (unless you create a Designer plugin, which is not very easy). This is achieved by setting the container layout margins, so that there's always enough space to show the values as text.
This is a possible implementation. Consider that, for obvious reasons, if you have a big value range with a small dial, the numbers will overlap.
class ValueDial(QtWidgets.QWidget):
_dialProperties = ('minimum', 'maximum', 'value', 'singleStep', 'pageStep',
'notchesVisible', 'tracking', 'wrapping',
'invertedAppearance', 'invertedControls', 'orientation')
_inPadding = 3
_outPadding = 2
valueChanged = QtCore.pyqtSignal(int)
def __init__(self, *args, **kwargs):
# remove properties used as keyword arguments for the dial
dialArgs = {k:v for k, v in kwargs.items() if k in self._dialProperties}
for k in dialArgs.keys():
kwargs.pop(k)
super().__init__(*args, **kwargs)
layout = QtWidgets.QVBoxLayout(self)
self.dial = QtWidgets.QDial(self, **dialArgs)
layout.addWidget(self.dial)
self.dial.valueChanged.connect(self.valueChanged)
# make the dial the focus proxy (so that it captures focus *and* key events)
self.setFocusProxy(self.dial)
# simple "monkey patching" to access dial functions
self.value = self.dial.value
self.setValue = self.dial.setValue
self.minimum = self.dial.minimum
self.maximum = self.dial.maximum
self.wrapping = self.dial.wrapping
self.notchesVisible = self.dial.notchesVisible
self.setNotchesVisible = self.dial.setNotchesVisible
self.setNotchTarget = self.dial.setNotchTarget
self.notchSize = self.dial.notchSize
self.invertedAppearance = self.dial.invertedAppearance
self.setInvertedAppearance = self.dial.setInvertedAppearance
self.updateSize()
def inPadding(self):
return self._inPadding
def setInPadding(self, padding):
self._inPadding = max(0, padding)
self.updateSize()
def outPadding(self):
return self._outPadding
def setOutPadding(self, padding):
self._outPadding = max(0, padding)
self.updateSize()
# the following functions are required to correctly update the layout
def setMinimum(self, minimum):
self.dial.setMinimum(minimum)
self.updateSize()
def setMaximum(self, maximum):
self.dial.setMaximum(maximum)
self.updateSize()
def setWrapping(self, wrapping):
self.dial.setWrapping(wrapping)
self.updateSize()
def updateSize(self):
# a function that sets the margins to ensure that the value strings always
# have enough space
fm = self.fontMetrics()
minWidth = max(fm.width(str(v)) for v in range(self.minimum(), self.maximum() + 1))
self.offset = max(minWidth, fm.height()) / 2
margin = self.offset + self._inPadding + self._outPadding
self.layout().setContentsMargins(margin, margin, margin, margin)
def translateMouseEvent(self, event):
# a helper function to translate mouse events to the dial
return QtGui.QMouseEvent(event.type(),
self.dial.mapFrom(self, event.pos()),
event.button(), event.buttons(), event.modifiers())
def changeEvent(self, event):
if event.type() == QtCore.QEvent.FontChange:
self.updateSize()
def mousePressEvent(self, event):
self.dial.mousePressEvent(self.translateMouseEvent(event))
def mouseMoveEvent(self, event):
self.dial.mouseMoveEvent(self.translateMouseEvent(event))
def mouseReleaseEvent(self, event):
self.dial.mouseReleaseEvent(self.translateMouseEvent(event))
def paintEvent(self, event):
radius = min(self.width(), self.height()) / 2
radius -= (self.offset / 2 + self._outPadding)
invert = -1 if self.invertedAppearance() else 1
if self.wrapping():
angleRange = 360
startAngle = 270
rangeOffset = 0
else:
angleRange = 300
startAngle = 240 if invert > 0 else 300
rangeOffset = 1
fm = self.fontMetrics()
# a reference line used for the target of the text rectangle
reference = QtCore.QLineF.fromPolar(radius, 0).translated(self.rect().center())
fullRange = self.maximum() - self.minimum()
textRect = QtCore.QRect()
qp = QtGui.QPainter(self)
qp.setRenderHints(qp.Antialiasing)
for p in range(0, fullRange + rangeOffset, self.notchSize()):
value = self.minimum() + p
if invert < 0:
value -= 1
if value < self.minimum():
continue
angle = p / fullRange * angleRange * invert
reference.setAngle(startAngle - angle)
textRect.setSize(fm.size(QtCore.Qt.TextSingleLine, str(value)))
textRect.moveCenter(reference.p2().toPoint())
qp.drawText(textRect, QtCore.Qt.AlignCenter, str(value))
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
dial = ValueDial(minimum=1, maximum=11)
dial.setNotchesVisible(True)
dial.show()
sys.exit(app.exec_())

how to use QGraphicsView::RubberBandDrag?

Can somebody please provide an explanation, or better yet a short example, of how to use the RubberBandDrag enum value in QGraphicsView? PyQt5 would be great, but I can translate from the C++ version if that is preferred for whomever can provide a helpful exmaple.
NoDrag and ScrollHandDrag are relatively easy to understand (NoDrag makes the mouse a pointer and you can capture clicks at certain locations, ScrollHandDrag makes the mouse a hand and you can implement click and drag to scroll around), but I'm unclear on what RubberBandDrag can be used for.
Before somebody says "go read the docs", here is the information provided
https://doc.qt.io/qt-5/qgraphicsview.html
enum QGraphicsView::DragMode
QGraphicsView::RubberBandDrag
A rubber band will appear. Dragging the mouse will set the rubber band
geometry, and all items covered by the rubber band are selected. This
mode is disabled for non-interactive views.
This is clear but I'm not sure how I could actually use RubberBandDrag. Is there a way to use this to drag points of a QPolygon around after initial placement? That would be really useful.
The QGraphicsView::RubberBandDrag flag only serves to activate the internal QRubberBand:
And the QRubberBand in general only aims to visualize a selected area and in the case of QGraphicsView select the items below that area if they are selectable(enable QGraphicsItem::ItemIsSelectable flag).
According to your last question: Is there a way to use this to drag points of a QPolygon around after initial placement? That would be really useful, it seems to me that you have an XY problem since it seems that the use of drag in the terminology makes you think that it serves to drag elements, because no, that drag refers to the way of creating the rubber band.
In a few moments I will show how to implement the drag of the vertices to modify the QPolygon.
The following shows how to modify the position of the vertices by dragging the mouse:
import math
from PyQt5 import QtCore, QtGui, QtWidgets
class GripItem(QtWidgets.QGraphicsPathItem):
circle = QtGui.QPainterPath()
circle.addEllipse(QtCore.QRectF(-10, -10, 20, 20))
square = QtGui.QPainterPath()
square.addRect(QtCore.QRectF(-15, -15, 30, 30))
def __init__(self, annotation_item, index):
super(GripItem, self).__init__()
self.m_annotation_item = annotation_item
self.m_index = index
self.setPath(GripItem.circle)
self.setBrush(QtGui.QColor("green"))
self.setPen(QtGui.QPen(QtGui.QColor("green"), 2))
self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable, True)
self.setFlag(QtWidgets.QGraphicsItem.ItemSendsGeometryChanges, True)
self.setAcceptHoverEvents(True)
self.setZValue(11)
self.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor))
def hoverEnterEvent(self, event):
self.setPath(GripItem.square)
self.setBrush(QtGui.QColor("red"))
super(GripItem, self).hoverEnterEvent(event)
def hoverLeaveEvent(self, event):
self.setPath(GripItem.circle)
self.setBrush(QtGui.QColor("green"))
super(GripItem, self).hoverLeaveEvent(event)
def mouseReleaseEvent(self, event):
self.setSelected(False)
super(GripItem, self).mouseReleaseEvent(event)
def itemChange(self, change, value):
if change == QtWidgets.QGraphicsItem.ItemPositionChange and self.isEnabled():
self.m_annotation_item.movePoint(self.m_index, value)
return super(GripItem, self).itemChange(change, value)
class PolygonAnnotation(QtWidgets.QGraphicsPolygonItem):
def __init__(self, parent=None):
super(PolygonAnnotation, self).__init__(parent)
self.m_points = []
self.setZValue(10)
self.setPen(QtGui.QPen(QtGui.QColor("green"), 2))
self.setAcceptHoverEvents(True)
self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, True)
self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable, True)
self.setFlag(QtWidgets.QGraphicsItem.ItemSendsGeometryChanges, True)
self.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor))
self.m_items = []
def addPoint(self, p):
self.m_points.append(p)
self.setPolygon(QtGui.QPolygonF(self.m_points))
item = GripItem(self, len(self.m_points) - 1)
self.scene().addItem(item)
self.m_items.append(item)
item.setPos(p)
def movePoint(self, i, p):
if 0 <= i < len(self.m_points):
self.m_points[i] = self.mapFromScene(p)
self.setPolygon(QtGui.QPolygonF(self.m_points))
def move_item(self, index, pos):
if 0 <= index < len(self.m_items):
item = self.m_items[index]
item.setEnabled(False)
item.setPos(pos)
item.setEnabled(True)
def itemChange(self, change, value):
if change == QtWidgets.QGraphicsItem.ItemPositionHasChanged:
for i, point in enumerate(self.m_points):
self.move_item(i, self.mapToScene(point))
return super(PolygonAnnotation, self).itemChange(change, value)
def hoverEnterEvent(self, event):
self.setBrush(QtGui.QColor(255, 0, 0, 100))
super(PolygonAnnotation, self).hoverEnterEvent(event)
def hoverLeaveEvent(self, event):
self.setBrush(QtGui.QBrush(QtCore.Qt.NoBrush))
super(PolygonAnnotation, self).hoverLeaveEvent(event)
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
scene = QtWidgets.QGraphicsScene()
w = QtWidgets.QGraphicsView(scene)
polygon_item = PolygonAnnotation()
scene.addItem(polygon_item)
r = 100
sides = 10
for i in range(sides):
angle = 2 * math.pi * i / sides
x = r * math.cos(angle)
y = r * math.sin(angle)
p = QtCore.QPointF(x, y) + QtCore.QPointF(200, 200)
polygon_item.addPoint(p)
w.resize(640, 480)
w.show()
sys.exit(app.exec_())

Error in super methods

I try using PyQt5 to connect two ellipses with a line. To do this, slightly changed the class taken with github. Instead of PySide, i use PyQt5.
The code is taken from here: https://github.com/PySide/Examples/blob/master/examples/graphicsview/diagramscene/diagramscene.py
class Arrow(QGraphicsLineItem):
def __init__(self, start_item, end_item, parent=None, scene=None):
super(Arrow, self).__init__(parent, scene)
self.arrowHead = QPolygonF()
self.my_start_item = start_item
self.my_end_item = end_item
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.my_color = QtCore.Qt.black
self.setPen(QPen(self.my_color, 2, QtCore.Qt.SolidLine,
QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin))
def set_color(self, color):
self.my_color = color
def start_item(self):
return self.my_start_item
def end_item(self):
return self.my_end_item
def boundingRect(self):
extra = (self.pen().width() + 20) / 2.0
p1 = self.line().p1()
p2 = self.line().p2()
return QtCore.QRectF(p1, QtCore.QSizeF(p2.x() - p1.x(), p2.y() - p1.y())).normalized().adjusted(-extra, -extra, extra, extra)
def shape(self):
path = super(Arrow, self).shape()
path.addPolygon(self.arrowHead)
return path
def update_position(self):
line = QtCore.QLineF(self.mapFromItem(self.my_start_item, 0, 0), self.mapFromItem(self.my_end_item, 0, 0))
self.setLine(line)
def paint(self, painter, option, widget=None):
if self.my_start_item.collidesWithItem(self.my_end_item):
return
my_start_item = self.my_start_item
my_end_item = self.my_end_item
my_color = self.my_color
my_pen = self.pen()
my_pen.setColor(self.my_color)
arrow_size = 20.0
painter.setPen(my_pen)
painter.setBrush(self.my_color)
center_line = QtCore.QLineF(my_start_item.pos(), my_end_item.pos())
end_polygon = my_end_item.polygon()
p1 = end_polygon.at(0) + my_end_item.pos()
intersect_point = QtCore.QPointF()
for i in end_polygon:
p2 = i + my_end_item.pos()
poly_line = QtCore.QLineF(p1, p2)
intersect_type, intersect_point = poly_line.intersect(center_line)
if intersect_type == QtCore.QLineF.BoundedIntersection:
break
p1 = p2
self.setLine(QtCore.QLineF(intersect_point, my_start_item.pos()))
line = self.line()
angle = math.acos(line.dx() / line.length())
if line.dy() >= 0:
angle = (math.pi * 2.0) - angle
arrow_p1 = line.p1() + QtCore.QPointF(math.sin(angle + math.pi / 3.0) * arrow_size,
math.cos(angle + math.pi / 3) * arrow_size)
arrow_p2 = line.p1() + QtCore.QPointF(math.sin(angle + math.pi - math.pi / 3.0) * arrow_size,
math.cos(angle + math.pi - math.pi / 3.0) * arrow_size)
self.arrowHead.clear()
for point in [line.p1(), arrow_p1, arrow_p2]:
self.arrowHead.append(point)
painter.drawLine(line)
painter.drawPolygon(self.arrowHead)
if self.isSelected():
painter.setPen(QPen(my_color, 1, QtCore.Qt.DashLine))
my_line = QtCore.QLineF(line)
my_line.translate(0, 4.0)
painter.drawLine(my_line)
my_line.translate(0, -8.0)
painter.drawLine(my_line)
Creating a arrow
arrow = Arrow(start, end, scene=scene)
scene.addItem(arrow)
arrow.update_position()
Error that occurs when the code is run
PyQt5 is a wrapper of Qt5 created by the company riverbankcomputing, and PySide is a wrapper of Qt4 created by the same creators of Qt at the time. Besides Qt4 and Qt5 have many differences, so these libraries are not compatible at all, for example in your case PySide items you can pass the scene as a parameter, but in the case of PyQt4 or PyQt5 that parameter is unnecessary:
PySide:
class PySide.QtGui.QGraphicsLineItem([parent=None[, scene=None]])
class PySide.QtGui.QGraphicsLineItem(line[, parent=None[, scene=None]])
class PySide.QtGui.QGraphicsLineItem(x1, y1, x2, y2[,parent=None[, scene=None]])
PyQt5:
QGraphicsLineItem(parent: QGraphicsItem = None)
QGraphicsLineItem(QLineF, parent: QGraphicsItem = None)
QGraphicsLineItem(float, float, float, float, parent: QGraphicsItem = None)
Besides that difference are the classes Signal() and pyqtSignal, and obviously the step from Qt4 to Qt5 supposed the separation of some classes that belonged to QtGui to QtWidgets.
The solution in your case is to eliminate the scene parameter, for it changes:
class Arrow(QGraphicsLineItem):
def __init__(self, start_item, end_item, parent=None, scene=None):
super(Arrow, self).__init__(parent, scene)
to:
class Arrow(QGraphicsLineItem):
def __init__(self, start_item, end_item, parent=None):
super(Arrow, self).__init__(parent)
I have translated that project to PyQt5, you can check it in the following link.

Categories