Draw an arc between two points on a tkinter canvas - python

My problem is very simple: I have two oval which centers are (x0, y0) and (x1, y1).
If I wanted to draw a line between them, I would simply do
create_line(x0, y0, x1, y1).
But I want to draw an arc between them. I'm struggling with the maths here. Here is the situation:
I have these two centers: they must be part of the ellipse
There is an infinity of ellipses going through these two points, but with tkinter, we can only draw horizontal ellipses. (right ?)
I need:
the top-left and lower-right coordinates of the rectangle that contains the ellipse
the start angle and the extent of the arc
I'm also thinking that maybe drawing an arc is the wrong way to go ? I could do something equivalent with a line, for which I specify a lot of points on that arc (even though it wouldn't be an actual arc)
Edit to answer Blindman:
With tkinter, you can define an arc like so:
http://infohost.nmt.edu/tcc/help/pubs/tkinter/web/create_oval.html
We don't define the ellipse per se, but only the top-left and lower-right corners coordinates of the rectangle that contains the ellipse.
How the two ovals relate to each other: they don't. You just have two oval at a random position on the canvas, and you want an arc between them.
I want the arc joining the two centers of these ovals.
Finally, here is an idea of what I want, in case the two oval have the same y coordinates:
What I want is exactly this, for any position.
And the tkinter code:
import tkinter as tk
class SampleApp(tk.Tk):
def __init__(self, *args, **kwargs):
tk.Tk.__init__(self, *args, **kwargs)
self.canvas = tk.Canvas(width=400, height=400)
self.canvas.pack(fill="both", expand=True)
self._create_token((100, 100), "white")
self._create_token((200, 100), "pink")
self.canvas.create_arc(100, 100 + 10, 200, 100 - 10, extent=180, style=tk.ARC)
def _create_token(self, coord, color):
'''Create a token at the given coordinate in the given color'''
(x,y) = coord
self.canvas.create_oval(x-5, y-5, x+5, y+5,
outline=color, fill=color, tags="token")
if __name__ == "__main__":
app = SampleApp()
app.mainloop()

There is a start option for creating the arc, which define where to start the drawing in giving angle. Using this and some math you can use the create_arc-method to draw an arc for any position:
import Tkinter as tk
class SampleApp(tk.Tk):
def __init__(self, *args, **kwargs):
tk.Tk.__init__(self, *args, **kwargs)
self.canvas = tk.Canvas(width=400, height=400)
self.canvas.pack(fill="both", expand=True)
self._create_token((100, 100), "white")
self._create_token((200, 300), "pink")
self._create_arc((100,100), (200, 300))
def _create_token(self, coord, color):
'''Create a token at the given coordinate in the given color'''
(x,y) = coord
self.canvas.create_oval(x-5, y-5, x+5, y+5,
outline=color, fill=color, tags="token")
def _create_arc(self, p0, p1):
extend_x = (self._distance(p0,p1) -(p1[0]-p0[0]))/2 # extend x boundary
extend_y = (self._distance(p0,p1) -(p1[1]-p0[1]))/2 # extend y boundary
startAngle = math.atan2(p0[0] - p1[0], p0[1] - p1[1]) *180 / math.pi # calculate starting angle
self.canvas.create_arc(p0[0]-extend_x, p0[1]-extend_y ,
p1[0]+extend_x, p1[1]+extend_y,
extent=180, start=90+startAngle, style=tk.ARC)
'''use this rectangle for visualisation'''
#self.canvas.create_rectangle(p0[0]-extend_x, p0[1]-extend_y,
# p1[0]+extend_x, p1[1]+extend_y)
def _distance(self, p0, p1):
'''calculate distance between 2 points'''
return sqrt((p0[0] - p1[0])**2 + (p0[1] - p1[1])**2)
if __name__ == "__main__":
app = SampleApp()
app.mainloop()

Actually, I will answer my own question, since I found a better way to do it.
VRage's answer is fine but it's hard to adapt in case you want the arc closer to the line between the two points.
The right way to do it is to use a line with a third point on the perpendicular bissector of the segment, combined with the "smooth" option.
Here is an example with exactly what I wanted:
import tkinter as tk
import math
class SampleApp(tk.Tk):
def __init__(self, *args, **kwargs):
tk.Tk.__init__(self, *args, **kwargs)
self.canvas = tk.Canvas(width=400, height=400)
self.canvas.pack(fill="both", expand=True)
def _create_token(self, coord, color):
'''Create a token at the given coordinate in the given color'''
(x,y) = coord
self.canvas.create_oval(x-5, y-5, x+5, y+5,
outline=color, fill=color, tags="token")
def create(self, xA, yA, xB, yB, d=10):
self._create_token((xA, yA), "white")
self._create_token((xB, yB), "pink")
t = math.atan2(yB - yA, xB - xA)
xC = (xA + xB)/2 + d * math.sin(t)
yC = (yA + yB)/2 - d * math.cos(t)
xD = (xA + xB)/2 - d * math.sin(t)
yD = (yA + yB)/2 + d * math.cos(t)
self.canvas.create_line((xA, yA), (xC, yC), (xB, yB), smooth=True)
self.canvas.create_line((xA, yA), (xD, yD), (xB, yB), smooth=True, fill="red")
if __name__ == "__main__":
app = SampleApp()
app.create(100, 100, 300, 250)
app.mainloop()

Related

QtWidgets.QGraphicsLineItem issue when setting viewport to QOpenGLWidget()

Situation
I have a PyQt5 app that shows lines, text and circles, it shows them correctly but the text rendering is a bit slow. I have a custom class for QGrapichsView that implement all this.
problem
When I set in the properties of the gv the following I start getting errors such as the example. The text and circles render correctly at a much faster render time(much better) but the lines get the error in rendering.
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
# format.setVersion(3, 0)
format.setProfile(QSurfaceFormat.CoreProfile)
self.gl_widget.setFormat(format)
self.setViewport(self.gl_widget)
the render of text get much much better and it shows them as it should. but a problem comes with the lines that start having strange behavior.
example with issue
example without issue
note how the width of the lines is variable even tough is set to a unique value, also, when I do a zoom out or zoom in, some of this lines appear and disappear randomly.
As soon as I use path item the problems begin, just a line item does not create this problem.
Does anybody have any idea what could this mean?
what to look for?
The issue is that the width of the lines are random, and not the set value I put in the code. Also when you zoom in or out, it disappears.
It seems to have something to do with the set width, as a bigger width helps, but does not remove it.
minimal reproducible example
import sys
from PyQt5.QtWidgets import QApplication, QGraphicsScene, QGraphicsTextItem
from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtWidgets import QOpenGLWidget
import numpy as np
from PyQt5.QtGui import QPainterPath, QPen
from PyQt5.QtWidgets import QGraphicsPathItem, QGraphicsLineItem, QGraphicsPolygonItem
from PyQt5.QtGui import QPolygonF
from PyQt5.QtCore import QLineF, QPointF
from PyQt5.QtGui import QSurfaceFormat
class GraphicsView(QtWidgets.QGraphicsView):
def __init__(self):
super(GraphicsView, self).__init__()
self.pos_init_class = None
# "VARIABLES INICIALES"
self.scale_factor = 1.5
# "ASIGNAR LINEAS DE MARCO"
self.setFrameShape(QtWidgets.QFrame.VLine)
# "ACTIVAR TRACKING DE POSICION DE MOUSE"
self.setMouseTracking(True)
# "REMOVER BARRAS DE SCROLL"
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
# "ASIGNAR ANCLA PARA HACER ZOOM SOBRE EL MISMO PUNTO"
self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse)
# "MEJORAR EL RENDER DE VECTORES"
self.setRenderHint(QtGui.QPainter.Antialiasing, False)
self.setRenderHint(QtGui.QPainter.SmoothPixmapTransform, False)
self.setRenderHint(QtGui.QPainter.TextAntialiasing, False)
self.setRenderHint(QtGui.QPainter.HighQualityAntialiasing, False)
self.setRenderHint(QtGui.QPainter.NonCosmeticDefaultPen, True)
self.setOptimizationFlag(QtWidgets.QGraphicsView.DontAdjustForAntialiasing, True)
self.setOptimizationFlag(QtWidgets.QGraphicsView.DontSavePainterState, True)
self.setCacheMode(QtWidgets.QGraphicsView.CacheBackground)
self.setViewportUpdateMode(QtWidgets.QGraphicsView.BoundingRectViewportUpdate)
#Try OpenGL stuff
# self.gl_widget = QOpenGLWidget()
# self.setViewport(self.gl_widget)
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
format.setVersion(2, 8)
format.setProfile(QSurfaceFormat.CoreProfile)
self.gl_widget.setFormat(format)
self.setViewport(self.gl_widget)
def mousePressEvent(self, event):
pos = self.mapToScene(event.pos())
# "PAN MOUSE"
if event.button() == QtCore.Qt.MiddleButton:
self.pos_init_class = pos
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.ClosedHandCursor)
super(GraphicsView, self).mousePressEvent(event)
def mouseReleaseEvent(self, event):
# PAN Y RENDER TEXT
if self.pos_init_class and event.button() == QtCore.Qt.MiddleButton:
# PAN
self.pos_init_class = None
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.ArrowCursor)
super(GraphicsView, self).mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
if self.pos_init_class:
# "PAN"
delta = self.pos_init_class - self.mapToScene(event.pos())
r = self.mapToScene(self.viewport().rect()).boundingRect()
self.setSceneRect(r.translated(delta))
super(GraphicsView, self).mouseMoveEvent(event)
def wheelEvent(self, event):
old_pos = self.mapToScene(event.pos())
# Determine the zoom factor
if event.angleDelta().y() > 0:
zoom_factor = self.scale_factor
else:
zoom_factor = 1 / self.scale_factor
# Apply the transformation to the view
transform = QtGui.QTransform()
transform.translate(old_pos.x(), old_pos.y())
transform.scale(zoom_factor, zoom_factor)
transform.translate(-old_pos.x(), -old_pos.y())
# Get the current transformation matrix and apply the new transformation to it
current_transform = self.transform()
self.setTransform(transform * current_transform)
def zoom_extent(self):
x_range, y_range, h_range, w_range = self.scene().itemsBoundingRect().getRect()
rect = QtCore.QRectF(x_range, y_range, h_range, w_range)
self.setSceneRect(rect)
unity = self.transform().mapRect(QtCore.QRectF(0, 0, 1, 1))
self.scale(1 / unity.width(), 1 / unity.height())
viewrect = self.viewport().rect()
scenerect = self.transform().mapRect(rect)
factor = min(viewrect.width() / scenerect.width(), viewrect.height() / scenerect.height())
self.scale(factor, factor)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.view = GraphicsView()
self.scene = QGraphicsScene()
self.view.setScene(self.scene)
self.generate_random_lines()
self.setCentralWidget(self.view)
self.showMaximized()
self.view.zoom_extent()
def rotate_vector(self, origin, point, angle):
"""
ROTATE A POINT COUNTERCLOCKWISE BY A GIVEN ANGLE AROUND A GIVEN ORIGIN. THE ANGLE SHOULD BE GIVEN IN RADIANS.
:param origin: SOURCE POINT ARRAYS, [X_SOURCE, Y_SOURCE], LEN N
:param point: DESTINATION POINT, [X_DEST, Y_DEST], LEN N
:param angle: ARRAY OF ANGLE TO ROTATE VECTOR (ORIGIN --> POINT), [ANG], LEN N
:return:
"""
ox, oy = origin
px, py = point
qx = ox + np.cos(angle) * (px - ox) - np.sin(angle) * (py - oy)
qy = oy + np.sin(angle) * (px - ox) + np.cos(angle) * (py - oy)
return qx, qy
def create_line_with_arrow_path(self, x1, y1, x2, y2, arr_width, arr_len):
"""
This function creates a line with an arrowhead at the end.
The line is created between two points (x1, y1) and (x2, y2).
The arrowhead is defined by its width (arr_width) and length (arr_len).
Returns a QGraphicsPathItem with the line and arrowhead.
"""
# Initialize the path for the line and arrowhead
path = QPainterPath()
path.moveTo(x1, y1)
path.lineTo(x2, y2)
# Calculate the midpoint of the line
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
# Define the points of the arrowhead
arrow_x = np.array([arr_width, -arr_len, -arr_width, -arr_len, arr_width]) * 5
arrow_y = np.array([0, arr_width, 0, -arr_width, 0]) * 5
arrow_x += mid_x
arrow_y += mid_y
# Calculate the angle of the line
angle = np.rad2deg(np.arctan2(y2 - y1, x2 - x1))
# Rotate the arrowhead points to align with the line
origin = (np.array([mid_x, mid_x, mid_x, mid_x, mid_x]), np.array([mid_y, mid_y, mid_y, mid_y, mid_y]))
point = (arrow_x, arrow_y)
self.x_init, self.y_init = self.rotate_vector(origin, point, np.deg2rad(angle))
# Add the arrowhead to the path
arrow_path = QtGui.QPainterPath()
arrow_path.moveTo(self.x_init[0], self.y_init[0])
for i in range(1, len(arrow_x)):
arrow_path.lineTo(self.x_init[i], self.y_init[i])
path.addPath(arrow_path)
# Create a QGraphicsPathItem with the line and arrowhead
item = QGraphicsPathItem(path)
pen = QPen()
pen.setWidthF(0.1)
item.setPen(pen)
return item, angle
def create_line_with_arrow_item(self, x1, y1, x2, y2, arr_width, arr_len):
# Calculate the midpoint of the line
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
# Define the coordinates for the arrow
arrow_x = np.array([arr_width, -arr_len, -arr_width, -arr_len, arr_width]) * 10
arrow_y = np.array([0, arr_width, 0, -arr_width, 0]) * 10
arrow_x += mid_x
arrow_y += mid_y
# Calculate the angle of the line
angle = np.rad2deg(np.arctan2(y2 - y1, x2 - x1))
# Rotate the arrow to align with the line
origin = (np.array([mid_x, mid_x, mid_x, mid_x, mid_x]), np.array([mid_y, mid_y, mid_y, mid_y, mid_y]))
point = (arrow_x, arrow_y)
x_init, y_init = self.rotate_vector(origin, point, np.deg2rad(angle))
# Create the line and arrow
line = QLineF(x1, y1, x2, y2)
arrow = QPolygonF([QPointF(x_init[0], y_init[0]),
QPointF(x_init[1], y_init[1]),
QPointF(x_init[2], y_init[2]),
QPointF(x_init[3], y_init[3]),
QPointF(x_init[4], y_init[4])])
item = QGraphicsLineItem(line)
item_arrow = QGraphicsPolygonItem(arrow)
# Set the pen for both line and arrow
pen = QPen()
pen.setWidthF(1)
item.setPen(pen)
item_arrow.setPen(pen)
# Return the line and arrow items
return item, item_arrow, angle
def generate_random_lines(self):
case = 'issue'
x = np.array([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) * 10
y = np.array([0, 20, 10, 0, 35, 90, 10, 60, 60, 90, 100]) * 10
for pos, i in enumerate(range(len(x) - 1)):
x1 = x[i]
y1 = y[i]
x2 = x[i + 1]
y2 = y[i + 1]
if case in ['issue']:
#add lines
path, angle = self.create_line_with_arrow_path(x1, y1, x2, y2, 0.5, 1.5)
self.scene.addItem(path)
# add text
text1 = QGraphicsTextItem()
text1.setPlainText(str(pos))
text1.setPos(x1, y1)
text1.setRotation(angle)
self.scene.addItem(text1)
else:
#add lines
line, arrow, angle = self.create_line_with_arrow_item(x1, y1, x2, y2, 0.5, 1.5)
self.scene.addItem(line)
self.scene.addItem(arrow)
# add text
text1 = QGraphicsTextItem()
text1.setPlainText(str(pos))
text1.setPos(x1, y1)
text1.setRotation(angle)
self.scene.addItem(text1)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())
figures for minimal example
this is an example whit the issue
this is an example without the issue
The issue is caused by two reasons.
Sampling of the OpenGL surface
When an analog object is shown in a digital context, aliasing always happens. That's the basic difference between real and integer numbers.
Now, the "issue" with OpenGL (and, from my understanding, any 3D basic visualization) is that if an object doesn't fill a whole pixel, by default that pixel won't be shown.
Anti-aliasing allows to tell if a pixel should be shown even if only a part of that pixel may be covered by the object. OpenGL is capable of multisample anti-aliasing, which provides an optimized spacial anti-aliasing: the amount of samples tells the engine if a pixel should be shown (and how) or not.
I'm not completely sure on how Qt decides the default sampling (my assumption is that it's based on the detected screen capabilities), but, in my tests and according to the documentation, the default is -1, meaning that no multisampling is enabled.
The convention I found normally uses 16 samples as default, so you need to do the following:
self.gl_widget = QOpenGLWidget()
format = QSurfaceFormat()
format.setSamples(16)
# ...
The pen width is too small
You're using a default pen width of 0.1, which is extremely small. Even with the standard raster rendering (without QOpenGlWidget as viewport), the pen would be almost invisible.
Imagine having a thread (I mean a physical yarn) that is very small in size: you can see it if you're close to it, but as you get far from it you'll eventually stop seeing it at some point.
Set a cosmetic pen whenever the scale is too big
The solution is quite simple, conceptually speaking: use the actual size as long as the scaling (the "zoom factor") allows to properly see the object.
Making it in practice requires some ingenuity.
Take these considerations for the following example:
I'm assuming a very basic scenario based on your example; for obvious reasons, you may need some further implementation, also considering that you're using different pen widths depending on the objects;
I'm using a mix-in class that takes advantage of the cooperative multi-inheritance followed by PyQt;
the implementation is quite basic and assumes that you always call setPen() to set a "default pen width"; you can obviously make your own classes using the pen width as argument in the constructor;
the "cosmetic pen" choice is based on arbitrary values; the assumption is that a pen becomes "non cosmetic" whenever a pixel ratio of penWidth * 4 would theoretically cover a whole pixel;
class VisibleGlShapeItem(object):
'''
A pseudo class that potentially overrides setPen() and provides a
custom method to override the default setPen() implementation.
'''
penWidth = None
def setPen(self, pen):
super().setPen(pen)
if self.penWidth is None:
self.penWidth = pen.widthF()
def setCosmeticPen(self, cosmeticScale):
if self.penWidth is None:
self.penWidth = .1
cosmetic = .5 / cosmeticScale > self.penWidth
pen = self.pen()
if pen.isCosmetic() != cosmetic:
pen.setCosmetic(cosmetic)
if cosmetic:
pen.setWidthF(.5)
else:
pen.setWidthF(self.penWidth)
super().setPen(pen)
# mixin classes creation
class VisibleGlPathItem(VisibleGlShapeItem, QGraphicsPathItem): pass
class VisibleGlLineItem(VisibleGlShapeItem, QGraphicsLineItem): pass
class GraphicsView(QtWidgets.QGraphicsView):
def __init__(self):
# ...
self.setRenderHint(QtGui.QPainter.Antialiasing) # mandatory
# ...
self.gl_widget = QOpenGLWidget()
format.setSamples(16)
# ...
def wheelEvent(self, event):
# ...
self.updatePens()
def showEvent(self, event):
# ensure that updatePens is called at least on first start
super().showEvent(event)
if not event.spontaneous():
self.updatePens()
def updatePens(self):
# get the minimum transformation scale; while you seem to be using
# a fixed ratio, a reference should always be considered
scale = min(self.transform().m11(), self.transform().m22())
for item in self.items():
if isinstance(item, QGraphicsPathItem):
item.setCosmeticPen(scale)
class MainWindow(QtWidgets.QMainWindow):
# ...
def create_line_with_arrow_path(self, x1, y1, x2, y2, arr_width, arr_len):
# ...
item = VisibleGlPathItem(path)
pen = QPen()
pen.setWidthF(0.1)
item.setPen(pen)
return item, angle
Final notes
avoid unnecessary renderHint flags; by default, QGraphicsView only uses TextAntialiasing, so, setting any other flag to False is completely pointless; also, both HighQualityAntialiasing and NonCosmeticDefaultPen are obsolete;
unless you actually need numpy for other reasons, avoid its requirement for mathematical purposes: either use the math module, or Qt capabilities; for instance, if you need to "rotate" a point around another, you can use QLineF, as it's normally quite fast and has the major benefit of better readability:
vector = QLineF(p1, p2)
vector.setAngle(angle)
newP2 = vector.p2()
merge/group items; lines shouldn't be separated by their arrows; if they share the same pen, just use a single QPainterPath (and QGraphicsPathItem), otherwise make the arrows as child items of the line;
use caching: there's no point in creating new paths for each arrow every time; just create one (using a 0-reference point) and add a translated copy to the new path whenever you need it; the result is conceptually the same, with the benefit of working on the C++ side of things;
setFrameShape(QtWidgets.QFrame.VLine) only makes sense for separators; don't use it for the wrong reason or widget;
setOverrideCursor() is intended for the whole application, and you shouldn't use it to temporarily change the cursor of a single widget; use setCursor() and unsetCursor() instead;

tkinter drag-and-drop when clicking near linear objects

In my user interface I want to allow a user to drag a crosshair over a canvas which will be displaying a photo. I observed that it is unreasonably difficult for user to click on the lines of a crosshair to get a "direct hit". I want to be able to drag if the users click is anywhere within the RADIUS of the crosshair, not just on one of the lines. The code here demonstrates this not working, even though my click function is carefully and successfully detecting a hit within that radius.
I would also like for the user to be able to click in a new location, have the crosshair appear there and then be able to drag it from that point.
So far, failing on both counts. You have to hit it directly on the circle or one of the lines in order to drag it. Can anyone suggest a clean fix?
import tkinter as tk
import math
CROSS_HAIR_SIZE = 30
class View(tk.Canvas):
def __init__(self, parent, width=1000, height=750):
super().__init__(parent, width=width, height=height)
self.pack(fill="both", expand=True)
self.crosspoint = [-100, -100]
self.bind("<Button-1>", self.click)
def crosshair(self, x, y, size, color, tag="cross"):
a = self.create_line(x - size, y, x + size, y, fill=color, width=1, tag=tag)
b = self.create_line(x, y - size, x, y + size, fill=color, width=1, tag=tag)
self.create_oval(x - size, y - size, x + size, y + size, outline=color, tag=tag)
self.crosspoint = [x, y]
def click(self, event):
click1(self, event, (event.x, event.y))
def startDragging(self, event, tag):
# note: dragPoint is carried in physical coordinates, not image coordinates
self.tag_bind(tag, "<ButtonRelease-1>", self.dragStop)
self.tag_bind(tag, "<B1-Motion>", self.drag)
self.dragPoint = (event.x, event.y)
self.dragTag = tag
def dragStop(self, event):
if not self.dragPoint: return
self.crosspoint = (event.x, event.y)
self.dragPoint = None
def drag(self, event):
if not self.dragPoint: return
# compute how much the mouse has moved
xy = (event.x, event.y)
delta = sub(xy, self.dragPoint)
self.move(self.dragTag, delta[0], delta[1])
self.dragPoint = xy
def sub(v1, v2):
return (v1[0] - v2[0], v1[1] - v2[1])
def click1(view, event, xy):
if math.dist(view.crosspoint, xy) <= CROSS_HAIR_SIZE:
view.startDragging(event, "cross")
print("drag inside radius")
else:
view.crosspoint = xy
# it's simplest to just start over with the crosshair
x, y = xy
view.delete("cross")
view.crosshair(x, y, CROSS_HAIR_SIZE, "red", tag="cross")
view.startDragging(event, "cross")
print("drag outside radius")
root = tk.Tk()
root.geometry("600x600")
view = View(root)
cross = view.crosshair(150, 150, CROSS_HAIR_SIZE, "red")
root.mainloop()

How to animate the creation of this arc in Tkinter? [duplicate]

I am trying to model a simple solar system in Tkinter using circles and moving them around in canvas. However, I am stuck trying to find a way to animate them. I looked around and found the movefunction coupled with after to create an animation loop. I tried fidgeting with the parameters to vary the y offset and create movement in a curved path, but I failed while trying to do this recursively or with a while loop. Here is the code I have so far:
import tkinter
class celestial:
def __init__(self, x0, y0, x1, y1):
self.x0 = x0
self.y0 = y0
self.x1 = x1
self.y1 = y1
sol_obj = celestial(200, 250, 250, 200)
sx0 = getattr(sol_obj, 'x0')
sy0 = getattr(sol_obj, 'y0')
sx1 = getattr(sol_obj, 'x1')
sy1 = getattr(sol_obj, 'y1')
coord_sol = sx0, sy0, sx1, sy1
top = tkinter.Tk()
c = tkinter.Canvas(top, bg='black', height=500, width=500)
c.pack()
sol = c.create_oval(coord_sol, fill='black', outline='white')
top.mainloop()
Here's something that shows one way to do what you want using the tkinter after method to update both the position of the object and the associated canvas oval object. It uses a generator function to compute coordinates along a circular path representing the orbit of one of the Celestial instances (named planet_obj1).
import math
try:
import tkinter as tk
except ImportError:
import Tkinter as tk # Python 2
DELAY = 100
CIRCULAR_PATH_INCR = 10
sin = lambda degs: math.sin(math.radians(degs))
cos = lambda degs: math.cos(math.radians(degs))
class Celestial(object):
# Constants
COS_0, COS_180 = cos(0), cos(180)
SIN_90, SIN_270 = sin(90), sin(270)
def __init__(self, x, y, radius):
self.x, self.y = x, y
self.radius = radius
def bounds(self):
""" Return coords of rectangle surrounding circlular object. """
return (self.x + self.radius*self.COS_0, self.y + self.radius*self.SIN_270,
self.x + self.radius*self.COS_180, self.y + self.radius*self.SIN_90)
def circular_path(x, y, radius, delta_ang, start_ang=0):
""" Endlessly generate coords of a circular path every delta angle degrees. """
ang = start_ang % 360
while True:
yield x + radius*cos(ang), y + radius*sin(ang)
ang = (ang+delta_ang) % 360
def update_position(canvas, id, celestial_obj, path_iter):
celestial_obj.x, celestial_obj.y = next(path_iter) # iterate path and set new position
# update the position of the corresponding canvas obj
x0, y0, x1, y1 = canvas.coords(id) # coordinates of canvas oval object
oldx, oldy = (x0+x1) // 2, (y0+y1) // 2 # current center point
dx, dy = celestial_obj.x - oldx, celestial_obj.y - oldy # amount of movement
canvas.move(id, dx, dy) # move canvas oval object that much
# repeat after delay
canvas.after(DELAY, update_position, canvas, id, celestial_obj, path_iter)
top = tk.Tk()
top.title('Circular Path')
canvas = tk.Canvas(top, bg='black', height=500, width=500)
canvas.pack()
sol_obj = Celestial(250, 250, 25)
planet_obj1 = Celestial(250+100, 250, 15)
sol = canvas.create_oval(sol_obj.bounds(), fill='yellow', width=0)
planet1 = canvas.create_oval(planet_obj1.bounds(), fill='blue', width=0)
orbital_radius = math.hypot(sol_obj.x - planet_obj1.x, sol_obj.y - planet_obj1.y)
path_iter = circular_path(sol_obj.x, sol_obj.y, orbital_radius, CIRCULAR_PATH_INCR)
next(path_iter) # prime generator
top.after(DELAY, update_position, canvas, planet1, planet_obj1, path_iter)
top.mainloop()
Here's what it looks like running:

how to redraw a line on a line which is already drawn in tkinter canvas smoothly?

I have some lines in tinter canvas, and also have their code. I want to make them red but not at a same time I want to draw another line(red line) go on them but it should take different time. for example fo one specific line it should take 3 seconds that line get red for another one it should take 7 seconds to make that red. it is like drawing another red line on the previous one.
def activator(self, hexagon, duration_time):
if not hexagon.is_end:
self.canvas.itemconfigure(hexagon.drawn, fill="tomato")
self.canvas.itemconfigure(hexagon.hex_aspects.outputs.drawn, fill="tomato")
for example I want my hexagon which created by createpolygon method of tinter get red but not immediately. It should do regarding to duration_time which is the a second variable. I mean it should be done within duration_time second(let say 3 second). is there any way for doing this? I have lots of object in my canvas which should get red during an specific time. line, circle, polygon..
A line on a tk.canvas is defined by a start and an end point; in order to access points on the line, we need to first create an affine line by first generating many points at an interval on the line, then join them with line segments.
This affine line is created upon clicking on an item on the canvas, but is hidden at first, and progressively revealed over a short time interval.
Once the redraw is completed, the affine line is hidden again, and the item being redrawn set to its new color.
This "simple" redraw requires quite a bit of machinery to implement. You can try it by clicking on a line to redraw it, and see the animation of the redraw.
Code:
import random
import tkinter as tk
WIDTH, HEIGHT = 500, 500
class AffinePoint:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return AffinePoint(self.x + other.x, self.y + other.y)
def __sub__(self, other):
return AffinePoint(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
return AffinePoint(self.x * scalar, self.y * scalar)
def __iter__(self):
yield self.x
yield self.y
def draw(self, canvas):
offset = AffinePoint(2, 2)
return canvas.create_oval(*(self + offset), *self - offset, fill='', outline='black')
def create_affine_points(canvas, num_points):
"""sanity check"""
for _ in range(num_points):
AffinePoint(random.randrange(0, WIDTH), random.randrange(0, HEIGHT)).draw(canvas)
class AffineLineSegment:
def __init__(self, start, end, num_t=100):
self.start = AffinePoint(*start)
self.end = AffinePoint(*end)
self.num_t = num_t
self.points = []
self._make_points()
self.segments = []
def _make_points(self):
for _t in range(self.num_t):
t = _t / self.num_t
self.points.append(self.start + (self.end - self.start) * t)
def __iter__(self):
for point in self.points:
yield point
def draw(self, canvas):
for p0, p1 in zip(self.points[:-1], self.points[1:]):
self.segments.append(canvas.create_line(*p0, *p1, width=5, state='hidden', fill='red'))
def hide(self, canvas):
for seg in self.segments:
canvas.itemconfigure(seg, state='hidden')
def create_affine_line(canvas, num_lines):
"""sanity check"""
for _ in range(num_lines):
start = random.randrange(0, WIDTH), random.randrange(0, HEIGHT)
end = random.randrange(0, WIDTH), random.randrange(0, HEIGHT)
AffineLineSegment(start, end).draw(canvas)
def select_and_redraw(event):
item = canvas.find_closest(event.x, event.y)[0]
x0, y0, x1, y1 = canvas.coords(item)
canvas.itemconfigure(item, fill='grey25')
canvas.itemconfigure(item, width=1)
a = AffineLineSegment((x0, y0), (x1, y1))
a.draw(canvas)
gen = (segment for segment in a.segments)
redraw(gen, a, item)
def redraw(gen, a, item):
try:
segment = next(gen)
canvas.itemconfigure(segment, state='normal')
root.after(10, redraw, gen, a, item)
except StopIteration:
a.hide(canvas)
canvas.itemconfigure(item, state='normal')
canvas.itemconfigure(item, fill='red')
canvas.itemconfigure(item, width=3)
finally:
root.after_cancel(redraw)
root = tk.Tk()
canvas = tk.Canvas(root, width=WIDTH, height=HEIGHT, bg="cyan")
canvas.pack()
canvas.bind('<ButtonPress-1>', select_and_redraw)
# sanity checks
# create_affine_points(canvas, 500)
# create_affine_line(canvas, 100)
for _ in range(10):
start = random.randrange(0, WIDTH), random.randrange(0, HEIGHT)
end = random.randrange(0, WIDTH), random.randrange(0, HEIGHT)
canvas.create_line(*start, * end, activefill='blue', fill='black', width=3)
root.mainloop()
Screen capture showing a line in the process of being redrawn
Try something like this
from tkinter import *
import numpy as np
root = Tk()
def lighter(color, percent):
color = np.array(color)
white = np.array([255, 255, 255])
vector = white-color
return tuple(color + vector * percent)
def Fade(line, start_rgb, percentage, times, delay):
'''assumes color is rgb between (0, 0, 0) and (255, 255, 255) adn percentage a value between 0.0 and 1.0'''
new_color = lighter(start_rgb, percentage)
red, blue, green = new_color
red = int(red)
blue = int(blue)
green = int(green)
new_hex = '#%02x%02x%02x' % (red, blue, green)
canvas.itemconfigure(line, fill=new_hex)
if times > 0:
root.after(delay, lambda: Fade(line, new_color, percentage, times - 1, delay))
canvas = Canvas(root, bg="black")
canvas.pack()
line = canvas.create_line(0, 0, 100, 100, width=10)
Fade(line, (0, 0, 50), 0.01, 1000, 10)
root.mainloop()

Animating an object to move in a circular path in Tkinter

I am trying to model a simple solar system in Tkinter using circles and moving them around in canvas. However, I am stuck trying to find a way to animate them. I looked around and found the movefunction coupled with after to create an animation loop. I tried fidgeting with the parameters to vary the y offset and create movement in a curved path, but I failed while trying to do this recursively or with a while loop. Here is the code I have so far:
import tkinter
class celestial:
def __init__(self, x0, y0, x1, y1):
self.x0 = x0
self.y0 = y0
self.x1 = x1
self.y1 = y1
sol_obj = celestial(200, 250, 250, 200)
sx0 = getattr(sol_obj, 'x0')
sy0 = getattr(sol_obj, 'y0')
sx1 = getattr(sol_obj, 'x1')
sy1 = getattr(sol_obj, 'y1')
coord_sol = sx0, sy0, sx1, sy1
top = tkinter.Tk()
c = tkinter.Canvas(top, bg='black', height=500, width=500)
c.pack()
sol = c.create_oval(coord_sol, fill='black', outline='white')
top.mainloop()
Here's something that shows one way to do what you want using the tkinter after method to update both the position of the object and the associated canvas oval object. It uses a generator function to compute coordinates along a circular path representing the orbit of one of the Celestial instances (named planet_obj1).
import math
try:
import tkinter as tk
except ImportError:
import Tkinter as tk # Python 2
DELAY = 100
CIRCULAR_PATH_INCR = 10
sin = lambda degs: math.sin(math.radians(degs))
cos = lambda degs: math.cos(math.radians(degs))
class Celestial(object):
# Constants
COS_0, COS_180 = cos(0), cos(180)
SIN_90, SIN_270 = sin(90), sin(270)
def __init__(self, x, y, radius):
self.x, self.y = x, y
self.radius = radius
def bounds(self):
""" Return coords of rectangle surrounding circlular object. """
return (self.x + self.radius*self.COS_0, self.y + self.radius*self.SIN_270,
self.x + self.radius*self.COS_180, self.y + self.radius*self.SIN_90)
def circular_path(x, y, radius, delta_ang, start_ang=0):
""" Endlessly generate coords of a circular path every delta angle degrees. """
ang = start_ang % 360
while True:
yield x + radius*cos(ang), y + radius*sin(ang)
ang = (ang+delta_ang) % 360
def update_position(canvas, id, celestial_obj, path_iter):
celestial_obj.x, celestial_obj.y = next(path_iter) # iterate path and set new position
# update the position of the corresponding canvas obj
x0, y0, x1, y1 = canvas.coords(id) # coordinates of canvas oval object
oldx, oldy = (x0+x1) // 2, (y0+y1) // 2 # current center point
dx, dy = celestial_obj.x - oldx, celestial_obj.y - oldy # amount of movement
canvas.move(id, dx, dy) # move canvas oval object that much
# repeat after delay
canvas.after(DELAY, update_position, canvas, id, celestial_obj, path_iter)
top = tk.Tk()
top.title('Circular Path')
canvas = tk.Canvas(top, bg='black', height=500, width=500)
canvas.pack()
sol_obj = Celestial(250, 250, 25)
planet_obj1 = Celestial(250+100, 250, 15)
sol = canvas.create_oval(sol_obj.bounds(), fill='yellow', width=0)
planet1 = canvas.create_oval(planet_obj1.bounds(), fill='blue', width=0)
orbital_radius = math.hypot(sol_obj.x - planet_obj1.x, sol_obj.y - planet_obj1.y)
path_iter = circular_path(sol_obj.x, sol_obj.y, orbital_radius, CIRCULAR_PATH_INCR)
next(path_iter) # prime generator
top.after(DELAY, update_position, canvas, planet1, planet_obj1, path_iter)
top.mainloop()
Here's what it looks like running:

Categories