GraphicsItem.py 14.9 KB
Newer Older
1
2
3
4
5
from pyqtgraph.Qt import QtGui, QtCore  
from pyqtgraph.GraphicsScene import GraphicsScene
from pyqtgraph.Point import Point
import weakref

Luke Campagnola's avatar
Luke Campagnola committed
6
class GraphicsItem(object):
7
    """
Luke Campagnola's avatar
Luke Campagnola committed
8
9
10
11
    **Bases:** :class:`object`

    Abstract class providing useful methods to GraphicsObject and GraphicsWidget.
    (This is required because we cannot have multiple inheritance with QObject subclasses.)
12
13
14
15

    A note about Qt's GraphicsView framework:

    The GraphicsView system places a lot of emphasis on the notion that the graphics within the scene should be device independent--you should be able to take the same graphics and display them on screens of different resolutions, printers, export to SVG, etc. This is nice in principle, but causes me a lot of headache in practice. It means that I have to circumvent all the device-independent expectations any time I want to operate in pixel coordinates rather than arbitrary scene coordinates. A lot of the code in GraphicsItem is devoted to this task--keeping track of view widgets and device transforms, computing the size and shape of a pixel in local item coordinates, etc. Note that in item coordinates, a pixel does not have to be square or even rectangular, so just asking how to increase a bounding rect by 2px can be a rather complex task.
16
    """
Luke Campagnola's avatar
Luke Campagnola committed
17
    def __init__(self, register=True):
18
19
20
21
22
23
24
25
        if not hasattr(self, '_qtBaseClass'):
            for b in self.__class__.__bases__:
                if issubclass(b, QtGui.QGraphicsItem):
                    self.__class__._qtBaseClass = b
                    break
        if not hasattr(self, '_qtBaseClass'):
            raise Exception('Could not determine Qt base class for GraphicsItem: %s' % str(self))
        
26
27
        self._viewWidget = None
        self._viewBox = None
28
        self._connectedView = None
Luke Campagnola's avatar
Luke Campagnola committed
29
30
        if register:
            GraphicsScene.registerObject(self)  ## workaround for pyqt bug in graphicsscene.items()
31
                    
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
    
    def getViewWidget(self):
        """
        Return the view widget for this item. If the scene has multiple views, only the first view is returned.
        The return value is cached; clear the cached value with forgetViewWidget()
        """
        if self._viewWidget is None:
            scene = self.scene()
            if scene is None:
                return None
            views = scene.views()
            if len(views) < 1:
                return None
            self._viewWidget = weakref.ref(self.scene().views()[0])
        return self._viewWidget()
47
    
48
49
    def forgetViewWidget(self):
        self._viewWidget = None
50
    
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
    def getViewBox(self):
        """
        Return the first ViewBox or GraphicsView which bounds this item's visible space.
        If this item is not contained within a ViewBox, then the GraphicsView is returned.
        If the item is contained inside nested ViewBoxes, then the inner-most ViewBox is returned.
        The result is cached; clear the cache with forgetViewBox()
        """
        if self._viewBox is None:
            p = self
            while True:
                p = p.parentItem()
                if p is None:
                    vb = self.getViewWidget()
                    if vb is None:
                        return None
                    else:
                        self._viewBox = weakref.ref(vb)
                        break
                if hasattr(p, 'implements') and p.implements('ViewBox'):
                    self._viewBox = weakref.ref(p)
                    break
        return self._viewBox()  ## If we made it this far, _viewBox is definitely not None

    def forgetViewBox(self):
        self._viewBox = None
        
        
    def deviceTransform(self, viewportTransform=None):
        """
        Return the transform that converts local item coordinates to device coordinates (usually pixels).
        Extends deviceTransform to automatically determine the viewportTransform.
        """
        if viewportTransform is None:
            view = self.getViewWidget()
            if view is None:
                return None
            viewportTransform = view.viewportTransform()
Luke Campagnola's avatar
Luke Campagnola committed
88
        dt = self._qtBaseClass.deviceTransform(self, viewportTransform)
89
90
91
92
93
94
95
96
        
        #xmag = abs(dt.m11())+abs(dt.m12())
        #ymag = abs(dt.m21())+abs(dt.m22())
        #if xmag * ymag == 0: 
        if dt.determinant() == 0:  ## occurs when deviceTransform is invalid because widget has not been displayed
            return None
        else:
            return dt
97
98
99
100
101
102
103
104
105
        
    def viewTransform(self):
        """Return the transform that maps from local coordinates to the item's ViewBox coordinates
        If there is no ViewBox, return the scene transform.
        Returns None if the item does not have a view."""
        view = self.getViewBox()
        if view is None:
            return None
        if hasattr(view, 'implements') and view.implements('ViewBox'):
106
107
108
109
            tr = self.itemTransform(view.innerSceneItem())
            if isinstance(tr, tuple):
                tr = tr[0]   ## difference between pyside and pyqt
            return tr
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
        else:
            return self.sceneTransform()
            #return self.deviceTransform(view.viewportTransform())



    def getBoundingParents(self):
        """Return a list of parents to this item that have child clipping enabled."""
        p = self
        parents = []
        while True:
            p = p.parentItem()
            if p is None:
                break
            if p.flags() & self.ItemClipsChildrenToShape:
                parents.append(p)
        return parents
    
    def viewRect(self):
        """Return the bounds (in item coordinates) of this item's ViewBox or GraphicsWidget"""
        view = self.getViewBox()
        if view is None:
            return None
133
134
135
136
137
        bounds = self.mapRectFromView(view.viewRect())
        if bounds is None:
            return None

        bounds = bounds.normalized()
138
139
140
141
142
143
144
145
146
        
        ## nah.
        #for p in self.getBoundingParents():
            #bounds &= self.mapRectFromScene(p.sceneBoundingRect())
            
        return bounds
        
        
        
147
148
149
150

    def pixelVectors(self, direction=None):
        """Return vectors in local coordinates representing the width and height of a view pixel.
        If direction is specified, then return vectors parallel and orthogonal to it.
151
        
152
153
        Return (None, None) if pixel size is not yet defined (usually because the item has not yet been displayed)."""

154
155
        dt = self.deviceTransform()
        if dt is None:
156
157
158
159
160
            return None, None
        
        if direction is None:
            direction = Point(1, 0)
            
161
        viewDir = Point(dt.map(direction) - dt.map(Point(0,0)))
162
163
164
165
166
167
168
169
170
        orthoDir = Point(viewDir[1], -viewDir[0])  ## orthogonal to line in pixel-space
        
        try:  
            normView = viewDir.norm()  ## direction of one pixel orthogonal to line
            normOrtho = orthoDir.norm()
        except:
            raise Exception("Invalid direction %s" %direction)
            
        
171
        dti = dt.inverted()[0]
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
        return Point(dti.map(normView)-dti.map(Point(0,0))), Point(dti.map(normOrtho)-dti.map(Point(0,0)))  
    
        #vt = self.deviceTransform()
        #if vt is None:
            #return None
        #vt = vt.inverted()[0]
        #orig = vt.map(QtCore.QPointF(0, 0))
        #return vt.map(QtCore.QPointF(1, 0))-orig, vt.map(QtCore.QPointF(0, 1))-orig
        
    def pixelLength(self, direction, ortho=False):
        """Return the length of one pixel in the direction indicated (in local coordinates)
        If ortho=True, then return the length of one pixel orthogonal to the direction indicated.
        
        Return None if pixel size is not yet defined (usually because the item has not yet been displayed).
        """
        normV, orthoV = self.pixelVectors(direction)
        if normV == None or orthoV == None:
            return None
        if ortho:
            return orthoV.length()
        return normV.length()
        
194
195
196
197
        

    def pixelSize(self):
        v = self.pixelVectors()
198
199
        if v == (None, None):
            return None, None
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
        return (v[0].x()**2+v[0].y()**2)**0.5, (v[1].x()**2+v[1].y()**2)**0.5

    def pixelWidth(self):
        vt = self.deviceTransform()
        if vt is None:
            return 0
        vt = vt.inverted()[0]
        return Point(vt.map(QtCore.QPointF(1, 0))-vt.map(QtCore.QPointF(0, 0))).length()
        
    def pixelHeight(self):
        vt = self.deviceTransform()
        if vt is None:
            return 0
        vt = vt.inverted()[0]
        return Point(vt.map(QtCore.QPointF(0, 1))-vt.map(QtCore.QPointF(0, 0))).length()
        
        
    def mapToDevice(self, obj):
        """
        Return *obj* mapped from local coordinates to device coordinates (pixels).
220
        If there is no device mapping available, return None.
221
222
223
224
225
226
227
228
229
        """
        vt = self.deviceTransform()
        if vt is None:
            return None
        return vt.map(obj)
        
    def mapFromDevice(self, obj):
        """
        Return *obj* mapped from device coordinates (pixels) to local coordinates.
230
        If there is no device mapping available, return None.
231
232
233
234
235
236
237
        """
        vt = self.deviceTransform()
        if vt is None:
            return None
        vt = vt.inverted()[0]
        return vt.map(obj)

238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
    def mapRectToDevice(self, rect):
        """
        Return *rect* mapped from local coordinates to device coordinates (pixels).
        If there is no device mapping available, return None.
        """
        vt = self.deviceTransform()
        if vt is None:
            return None
        return vt.mapRect(rect)

    def mapRectFromDevice(self, rect):
        """
        Return *rect* mapped from device coordinates (pixels) to local coordinates.
        If there is no device mapping available, return None.
        """
        vt = self.deviceTransform()
        if vt is None:
            return None
        vt = vt.inverted()[0]
        return vt.mapRect(rect)
    
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
    def mapToView(self, obj):
        vt = self.viewTransform()
        if vt is None:
            return None
        return vt.map(obj)
        
    def mapRectToView(self, obj):
        vt = self.viewTransform()
        if vt is None:
            return None
        return vt.mapRect(obj)
        
    def mapFromView(self, obj):
        vt = self.viewTransform()
        if vt is None:
            return None
        vt = vt.inverted()[0]
        return vt.map(obj)

    def mapRectFromView(self, obj):
        vt = self.viewTransform()
        if vt is None:
            return None
        vt = vt.inverted()[0]
        return vt.mapRect(obj)

    def pos(self):
Luke Campagnola's avatar
Luke Campagnola committed
286
        return Point(self._qtBaseClass.pos(self))
287
288
    
    def viewPos(self):
Luke Campagnola's avatar
Luke Campagnola committed
289
        return self.mapToView(self.mapFromParent(self.pos()))
290
291
292
    
    def parentItem(self):
        ## PyQt bug -- some items are returned incorrectly.
Luke Campagnola's avatar
Luke Campagnola committed
293
        return GraphicsScene.translateGraphicsItem(self._qtBaseClass.parentItem(self))
294
        
Luke Campagnola's avatar
Luke Campagnola committed
295
296
297
298
299
300
301
    def setParentItem(self, parent):
        ## Workaround for Qt bug: https://bugreports.qt-project.org/browse/QTBUG-18616
        if parent is not None:
            pscene = parent.scene()
            if pscene is not None and self.scene() is not pscene:
                pscene.addItem(self)
        return self._qtBaseClass.setParentItem(self, parent)
302
303
304
    
    def childItems(self):
        ## PyQt bug -- some child items are returned incorrectly.
Luke Campagnola's avatar
Luke Campagnola committed
305
        return list(map(GraphicsScene.translateGraphicsItem, self._qtBaseClass.childItems(self)))
306
307
308
309
310
311
312
313
314


    def sceneTransform(self):
        ## Qt bug: do no allow access to sceneTransform() until 
        ## the item has a scene.
        
        if self.scene() is None:
            return self.transform()
        else:
Luke Campagnola's avatar
Luke Campagnola committed
315
            return self._qtBaseClass.sceneTransform(self)
316
317
318
319
320
321
322
323
324


    def transformAngle(self, relativeItem=None):
        """Return the rotation produced by this item's transform (this assumes there is no shear in the transform)
        If relativeItem is given, then the angle is determined relative to that item.
        """
        if relativeItem is None:
            relativeItem = self.parentItem()
            
325
326
327
328

        tr = self.itemTransform(relativeItem)
        if isinstance(tr, tuple):  ## difference between pyside and pyqt
            tr = tr[0]  
329
330
331
332
        vec = tr.map(Point(1,0)) - tr.map(Point(0,0))
        return Point(vec).angle(Point(1,0))
        
        
333
    #def itemChange(self, change, value):
Luke Campagnola's avatar
Luke Campagnola committed
334
        #ret = self._qtBaseClass.itemChange(self, change, value)
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
        #if change == self.ItemParentHasChanged or change == self.ItemSceneHasChanged:
            #print "Item scene changed:", self
            #self.setChildScene(self)  ## This is bizarre.
        #return ret

    #def setChildScene(self, ch):
        #scene = self.scene()
        #for ch2 in ch.childItems():
            #if ch2.scene() is not scene:
                #print "item", ch2, "has different scene:", ch2.scene(), scene
                #scene.addItem(ch2)
                #QtGui.QApplication.processEvents()
                #print "   --> ", ch2.scene()
            #self.setChildScene(ch2)

    def _updateView(self):
        ## called to see whether this item has a new view to connect to
        ## NOTE: This is called from GraphicsObject.itemChange or GraphicsWidget.itemChange.

        ## It is possible this item has moved to a different ViewBox or widget;
        ## clear out previously determined references to these.
        self.forgetViewBox()
        self.forgetViewWidget()
358
        
359
360
        ## check for this item's current viewbox or view widget
        view = self.getViewBox()
361
362
363
        #if view is None:
            ##print "  no view"
            #return
364

365
366
367
368
369
        oldView = None
        if self._connectedView is not None:
            oldView = self._connectedView()
            
        if view is oldView:
370
371
372
373
            #print "  already have view", view
            return

        ## disconnect from previous view
374
375
376
377
        if oldView is not None:
            #print "disconnect:", self, oldView
            oldView.sigRangeChanged.disconnect(self.viewRangeChanged)
            self._connectedView = None
378
379

        ## connect to new view
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
        if view is not None:
            #print "connect:", self, view
            view.sigRangeChanged.connect(self.viewRangeChanged)
            self._connectedView = weakref.ref(view)
            self.viewRangeChanged()
        
        ## inform children that their view might have changed
        self._replaceView(oldView)
        
        
    def _replaceView(self, oldView, item=None):
        if item is None:
            item = self
        for child in item.childItems():
            if isinstance(child, GraphicsItem):
                if child.getViewBox() is oldView:
                    child._updateView()
                        #self._replaceView(oldView, child)
            else:
                self._replaceView(oldView, child)
        
        
402
403
404
405
406
407

    def viewRangeChanged(self):
        """
        Called whenever the view coordinates of the ViewBox containing this item have changed.
        """
        pass