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
Related
I am trying to make a custom animated button on PyQt. I found a website which has custom buttons: Buttons website
I already created a topic for making a 3rd button: Stackoverflow for 3rd button
#musicamante helped for the 3rd button, thank you very much again. Now I'm trying to make the 19th button.
My code for 19th button:
import sys, os, time
from math import *
from PySide6 import QtCore, QtWidgets, QtGui
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *
class Button19(QPushButton):
Radius = 10
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.backgroundColors = (QtGui.QColor(QtCore.Qt.lightGray),QtGui.QColor(QtCore.Qt.white))
self.foregroundColors = (QtGui.QColor(QtCore.Qt.black), QtGui.QColor(QtCore.Qt.lightGray))
font = self.font()
font.setBold(True)
self.setFont(font)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.hoverAnimation = QtCore.QVariantAnimation(self)
self.hoverAnimation.setStartValue(0.)
self.hoverAnimation.setEndValue(1.)
self.hoverAnimation.setEasingCurve(QtCore.QEasingCurve.OutCubic)
self.hoverAnimation.setDuration(400)
self.hoverAnimation.valueChanged.connect(self.update)
self.setText("Button")
_m_isHover = False
def enterEvent(self, event):
super().enterEvent(event)
self._m_isHover = True
self.hoverAnimation.setDirection(self.hoverAnimation.Forward)
self.hoverAnimation.start()
def leaveEvent(self, event):
super().leaveEvent(event)
self._m_isHover = False
self.hoverAnimation.setDirection(self.hoverAnimation.Backward)
self.hoverAnimation.start()
def isHover(self):
return self._m_isHover
def paintEvent(self, event):
aniValue = self.hoverAnimation.currentValue()
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
path, path2 = QPainterPath(), QPainterPath()
painter.setBrush(QBrush(self.backgroundColors[0]))
painter.setPen(Qt.NoPen)
rect = QRectF(0, 0, self.width(), self.height())
padding = 10
rect = rect.adjusted(-padding * aniValue, -padding * aniValue, padding * aniValue, padding * aniValue)
path.addRoundedRect(rect.adjusted(padding / 2, padding, -padding / 2, -padding), self.Radius, self.Radius)
painter.drawPath(path)
painter.setBrush(QBrush(self.foregroundColors[0]))
painter.setClipPath(path)
radiusEffectSize = 75
path2.addEllipse(self.rect().center(), radiusEffectSize * aniValue, radiusEffectSize * aniValue)
painter.drawPath(path2)
if self.isHover() or self.hoverAnimation.currentValue() > 0.1: # when leaveEvent triggered, still background color black. So must wait to change textcolor (ofcourse there is probably a better way)
painter.setPen(self.foregroundColors[1])
else:
painter.setPen(self.foregroundColors[0])
painter.drawText(self.rect(), Qt.AlignCenter, self.text())
if __name__ == "__main__":
app = QApplication(sys.argv)
wind = QMainWindow()
wind.setStyleSheet("QMainWindow{background-color:rgb(247,247,250)}")
wind.resize(150, 80)
wid = QWidget()
lay = QHBoxLayout(wid)
lay.setAlignment(Qt.AlignCenter)
mycustombutton = Button19()
lay.addWidget(mycustombutton)
wind.setCentralWidget(wid)
wind.show()
sys.exit(app.exec())
Still feels different, not the same. I need help, thanks!
The main issue in your code is that the padding computation is wrong.
You are increasing the size of the padding from the current rectangle and then decrease it by half the padding size, which doesn't make a lot of sense.
You should instead consider the default padding minus the extent based on the animation value, then adjust (reduce) the rectangle based to it:
padding = 10 * (1 - aniValue)
path.addRoundedRect(
rect.adjusted(padding, padding, -padding, -padding),
self.Radius, self.Radius
)
That will not be sufficient, though: the radius has to consider the actual size of the widget, but that can be misleading: if you take the smaller dimension (between width and height) the ellipse could be smaller than the rectangle, while in the opposite case it would grow up too early, making the animation quite odd. The actual radius should actually be computed using the hypotenuse of the right triangle of the widget width and height (a "perfect" implementation should also consider the radius of the rounded rectangle, but that would be quite too much):
# using hypot() from the math module
radius = hypot(self.width(), self.height()) / 2
path2.addEllipse(self.rect().center(), radius, radius)
Not enough, though: if you closely look at the original animation, you'll see that the "leave" event will not be the same: there is no circle, the "black" rounded rectangle just fades out. We need to take care of that too:
radius = min(self.width(), self.height())
if (self.hoverAnimation.state()
and self.hoverAnimation.direction() == self.hoverAnimation.Forward):
radius *= aniValue
# always full opacity on "fade in"
opacity = 1.
else:
# "fade out"
opacity = aniValue
path2.addEllipse(self.rect().center(), radius, radius)
painter.save()
painter.setOpacity(opacity)
painter.drawPath(path2)
painter.restore()
Nearly there. But the text drawing still has issues. First of all, the "base" should always be painted, and the "hover" should be painted over with the opacity value specified above (unless you want an alpha value). Then, we should always remember that buttons could also use "mnemonics" (keyboard shortcuts that are always highlighted with an underlined character, specified with a preceding & in Qt).
For optimization reasons, it's better to "replicate" similar functions instead of using local variables. It might not be wonderful for reading purposes, but painting functions should be always try to be as fast as possible.
So, here's the final result:
def paintEvent(self, event):
aniValue = self.hoverAnimation.currentValue()
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(QBrush(self.backgroundColors[0]))
painter.setPen(Qt.NoPen)
rect = self.rect()
path = QPainterPath()
padding = 10 * (1 - aniValue)
path.addRoundedRect(
QRectF(rect).adjusted(padding, padding, -padding, -padding),
self.Radius, self.Radius
)
painter.setClipPath(path)
painter.drawPath(path)
painter.setBrush(QBrush(self.foregroundColors[0]))
if aniValue < 1:
# only draw the default text when the animation isn't finished yet
painter.setPen(self.foregroundColors[0])
painter.drawText(rect, Qt.AlignCenter|Qt.TextShowMnemonic, self.text())
if not aniValue:
# no hover, just ignore the rest
return
hoverPath = QPainterPath()
radius = hypot(self.width(), self.height()) / 2
if (aniValue and self.hoverAnimation.state()
and self.hoverAnimation.direction() == self.hoverAnimation.Forward):
hoverPath.addEllipse(rect.center(),
radius * aniValue, radius * aniValue)
painter.drawPath(hoverPath)
else:
hoverPath.addEllipse(rect.center(), radius, radius)
painter.save()
painter.setOpacity(aniValue)
painter.drawPath(hoverPath)
painter.restore()
painter.setPen(self.foregroundColors[1])
painter.drawText(rect, Qt.AlignCenter|Qt.TextShowMnemonic, self.text())
Some further notes:
isHover() is quite pointless unless you need it for something else but painting: except from extreme performance requirements (for which value caching would make sense), underMouse() is usually sufficient; for this case, it is also a bit irrelevant, as we can be quite sure that the hover state only happens when the animation value is 1 or the animation is active (animation.state()) and its direction is Forward;
the "smoothness" of the animation completely depends on its easingCurve(), so please do experiment with all available curves to find what best suits your needs;
when working with plain shapes and no borders ("pens"), Qt normally works fine, as it happens with the code above, but be aware that painting with pixel-based devices (as QWidgets) could create artifacts while using anti-aliasing; in that case you have to consider the "pen width" and translate the drawing by half its size to obtain a "perfect" shape on the screen;
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:])
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_())
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_())
I cannot figure this out for the life of me, but I've boiled this down to a self contained problem.
What I am trying to do, is draw a QGraphicsRectItem around the items that are selected in a QGraphicsScene. After the rect is drawn it can be moved in a way that moves all of the items together. I've looked into QGraphicsItemGroup already and decided it is not feasible in my real use case.
The problem: I've been able to accomplish everything mentioned above, except I can't get the rect item to be positioned properly i.e. it is the right size and by moving it all items are moved but it is not lined up with the united bounding rect of the selected items. I've tried to keep everything in scene coordinates so I'm not sure why there is an offset.
Why does there appear to be an offset and how can this be mitigated?
Here is the runnable code that can be tested by ctrl-clicking or rubber band selection (I know this is a good amount of code but the relevant sections are commented).
#####The line where the position of the rect item is set is marked like this#####
from PyQt4.QtGui import *
from PyQt4.QtCore import *
import sys
class DiagramScene(QGraphicsScene):
def __init__(self, parent=None):
super().__init__(parent)
self.selBox = None
self.selectionChanged.connect(self.onSelectionChange)
#pyqtSlot()
def onSelectionChange(self):
count = 0
items = self.selectedItems()
# Get bounding rect of all selected Items
for item in self.selectedItems():
if count == 0:
rect = item.mapRectToScene(item.boundingRect())
else:
rect = rect.unite(item.mapRectToScene(item.boundingRect()))
count += 1
if count > 0:
if self.selBox:
# Update selBox if items are selected and already exists
self.selBox.setRect(rect)
self.selBox.items = items
else:
# Instantiate selBox if items are selected and does not already exist
self.selBox = DiagramSelBox(rect, items)
##### Set position of selBox to topLeft corner of united rect #####
self.selBox.setPos(rect.topLeft())
self.addItem(self.selBox)
elif self.selBox:
# Remove selBox from scene if no items are selected and box is drawn
self.removeItem(self.selBox)
del self.selBox
self.selBox = None
class DiagramSelBox(QGraphicsRectItem):
def __init__(self, bounds, items, parent=None, scene=None):
super().__init__(bounds, parent, scene)
self.setFlag(QGraphicsItem.ItemIsSelectable, True)
self.pressPos = None
self.items = items
def paint(self, painter, option, widget=None):
pen = QPen(Qt.DashLine)
painter.setPen(pen)
painter.drawRect(self.rect())
def mousePressEvent(self, e):
# Get original position of selBox when clicked
self.pressPos = self.pos()
# mouseEvent is not passed on to scene so item selection
# does not change
def mouseMoveEvent(self, e):
super().mouseMoveEvent(e)
if self.pressPos:
# Move selBox is original position is set
newPos = self.mapToScene(e.pos()) - self.rect().center()
self.setPos(newPos)
def mouseReleaseEvent(self, e):
# Update position of all selected items
change = self.scenePos() - self.pressPos
for item in self.items:
item.moveBy(change.x(), change.y())
super().mouseReleaseEvent(e)
if __name__ == "__main__":
app = QApplication(sys.argv)
view = QGraphicsView()
view.setDragMode(QGraphicsView.RubberBandDrag)
scene = DiagramScene()
scene.setSceneRect(0, 0, 500, 500)
rect1 = scene.addRect(20, 20, 100, 50)
rect2 = scene.addRect(80, 80, 100, 50)
rect3 = scene.addRect(140, 140, 100, 50)
rect1.setFlag(QGraphicsItem.ItemIsSelectable, True)
rect2.setFlag(QGraphicsItem.ItemIsSelectable, True)
rect3.setFlag(QGraphicsItem.ItemIsSelectable, True)
view.setScene(scene)
view.show()
sys.exit(app.exec_())
I don't have PyQt installed, but I've run into similar issues with the regular QT and QGraphicsRectItem.
I think you've mixed some things up regarding the coordinate system. The bounding-rect of every QGraphicsItem is in local coordinates. The Point (0,0) in local-coordinates appears at the scene on the coordinates given by QGraphicsItem::pos() (scene-coordiantes).
QGraphicsRectItem is a bit special, because we normally don't touch pos at all (so we leave it at 0,0) and pass a rect in scene-coordinates to setRect. QGraphicsRectItem::setRect basically set's the bounding rect to the passed value. So if you don't call setPos (in onSelectionChange) at all, and only pass scene-coordinates to setRect you should be fine.
The mouseEvents in DiagramSelBox need to be adjusted as well. My approach would look like this:
mousePress: store the difference between e.pos (mapped to scene) and self.rect.topLeft() in self.diffPos and copy self.rect.topLeft to self.startPos
mouseMove: ensure that the difference between e.pos (mapped to scene) and self.rect.topLeft() stays the same, by moving self.rect around (use self.diffPos for the calculation)
mouseRelease: move the items by the difference between self.rect.topLeft() and self.startPos.
Hope that helps to get you started.