Skip to content

Instantly share code, notes, and snippets.

@mhugo
Created June 29, 2016 13:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mhugo/fbfa5d15b95787d20f5e8f7863441036 to your computer and use it in GitHub Desktop.
Save mhugo/fbfa5d15b95787d20f5e8f7863441036 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python
# This set of classes demonstrate how to implement a defered renderer with QT
#
# The idea is to be source compatible with existing QPainter operations (drawXXX, setBrush, etc.)
# and add extra commands that allow to "insert back" operations in the pipeline before they are actually rendered.
#
# This is a possible component for the "symbol clipping" feature of QGIS
#
# This prototype implements a custom QPaintDevice / QPaintEngine that will
# store painting commands in a pipe and actually render everything when end() is called.
#
# The advantage of this approach is that QPaintEngine is the "low level" component
# of a QT rendering, and very few methods need to be overloaded (about 10 virtual methods in QPaintEngine)
#
# The main drawback identified so far is that the QPaintEngine commands have not been designed to be
# copied and stored. For example, there is no way to copy and store the QPaintEngineState of updateState for
# deferred call.
# In order to circumvent this problem, a local QPainter is created on the proxied device.
# QPaintEngine commands are then translated to QPainter commands.
# ... which could lead to poor performances.
#
# For instance, there is no QPainter direct equivalent to QPaintEngine::drawTextItem, the engine
# will then call a QPainter method (drawText) that will trigger new computations that have probably already been
# done by the caller.
#
# Inspiration: see https://github.com/KDE/libkdegames/blob/master/colorproxy_p.cpp
#
# One alternative is to "overload" a QPainter that would stack and forward calls to another embedded QPainter.
# But since QPainter has no virtual methods, it would mean creating a new "proxy" class (QgsPainter?) and
# either implement every painter methods used in QGIS symbology or restrict them to a fixed subset.
import sys
from PyQt4.QtCore import *
from PyQt4.QtGui import *
class EngineStateCopy:
"""QPaintEngineState cannot be copied. This class serves as a clone in order to defer access to its properties"""
def __init__(self, state):
self.__state = state.state()
self.__brush = state.brush()
self.__pen = state.pen()
self.__hints = state.renderHints()
self.__font = state.font()
def state(self):
return self.__state
def pen(self):
return self.__pen
def brush(self):
return self.__brush
def renderHints(self):
return self.__hints
def font(self):
return self.__font
#
# This class implements a defered rendering (pipeline)
#
# Every painting operations are stored (and its arguments copied)
# on end(), the command lists in the pipe are processed in order
# and send back to the original paint device
# some extra commands are available to define clipping masks
class MyPaintEngine(QPaintEngine):
def __init__(self):
QPaintEngine.__init__(self)
self.pdevice = None
self.commands = []
self.layerMask = {}
def begin(self, pdevice):
self.pdevice = pdevice
return True
def paintDevice(self):
return self.pdevice
def end(self):
# First pass: insert clipping commands
painter = QPainter()
painter.begin(self.pdevice.proxied())
# unstack all commands
currentLayer = "global"
for cmd in self.commands:
fname = cmd[0]
args = cmd[1:]
if fname == "updateState":
state = args[0]
if state.state() & QPaintEngine.DirtyPen:
painter.setPen(state.pen())
if state.state() & QPaintEngine.DirtyBrush:
painter.setBrush(state.brush())
if state.state() & QPaintEngine.DirtyHints:
painter.setRenderHints(state.renderHints())
elif fname == 'drawText':
pos, width, height, font, text = args
painter.setFont(font)
# !!!!! May recompute things already computed in the TextItem !!!!
painter.drawText(pos, text)
elif fname == "layerChange":
# insert clipPath if any
layer = cmd[1]
mask = self.layerMask.get(layer)
if mask is None:
# unset clip
painter.setClipPath(QPainterPath(), Qt.NoClip)
else:
# set the clip path
w = self.pdevice.proxied().width()
h = self.pdevice.proxied().height()
path = QPainterPath()
path.addRect(0, 0, w, h)
path = path.subtracted(mask)
painter.setClipPath(path)
else:
# call QPainter's method for everything else
foo = getattr(painter, fname)
foo(*args)
return painter.end()
def updateState(self, state):
self.commands.append(('updateState', EngineStateCopy(state)))
def drawLines(self, lines):
self.commands.append(('drawLines', list(lines)))
def drawRects(self, rects):
self.commands.append(('drawRects', list(rects)))
def drawTextItem(self, p, textitem):
self.commands.append(('drawText', p, textitem.width(), textitem.ascent() + textitem.descent(), textitem.font(), textitem.text()))
def drawEllipse(self, rect):
# TODO
pass
def drawImage(self, rect, image, sr, flags):
# TODO
pass
def drawPath(self, path):
# TODO
pass
def drawPixmap(self, r, pm, sr):
# TODO
pass
def drawPoints(self, points):
# TODO
pass
def drawPolygon(self, points, mode):
# TODO
pass
def drawTiledPixmap(rect, pixmap, p):
# TODO
pass
def setCurrentLayer(self, layer):
self.commands.append(("layerChange", layer))
def setClipMask(self, path, layers):
for layer in layers:
self.layerMask[layer] = path
class MyPaintDevice(QPaintDevice):
def __init__(self, proxied_paint_device):
QPaintDevice.__init__(self)
self.pdevice = proxied_paint_device
self.paint_engine = MyPaintEngine()
def proxied(self):
return self.pdevice
def metric(self, m):
if m == QPaintDevice.PdmWidth:
return self.pdevice.width()
if m == QPaintDevice.PdmHeight:
return self.pdevice.height()
if m == QPaintDevice.PdmWidthMM:
return self.pdevice.widthMM()
if m == QPaintDevice.PdmHeightMM:
return self.pdevice.heightMM()
if m == QPaintDevice.PdmNumColors:
return self.pdevice.colorCount()
if m == QPaintDevice.PdmDepth:
return self.pdevice.depth()
if m == QPaintDevice.PdmDpiX:
return self.pdevice.logicalDpiX()
if m == QPaintDevice.PdmDpiY:
return self.pdevice.logicalDpiY()
if m == QPaintDevice.PdmPhysicalDpiX:
return self.pdevice.physicalDpiX()
if m == QPaintDevice.PdmPhysicalDpiY:
return self.pdevice.physicalDpiY()
return 0
def paintEngine(self):
return self.paint_engine
class MyPainter(QPainter):
# QPainter augmented with two methods
def __init__(self, device = None):
if device is None:
QPainter.__init__(self)
else:
QPainter.__init__(self, device)
def setCurrentLayer(self, layer):
"Sets the current layer where every following painting commands will be part of"
self.paintEngine().setCurrentLayer(layer)
def setClipMask(self, path, layers):
"""Sets the clip mask for a set of layers
:param path: a QPainterPath
:param layers: a list of layers defined by setCurrentLayer
TODO: do the union with existing masks
"""
self.paintEngine().setClipMask(path, layers)
def commands(self):
"Debug method that dumps the content of the painting command fifo"
return self.paintEngine().commands
class Window(QWidget):
def __init__(self):
QWidget.__init__(self)
def paintEvent(self, event):
pdevice = MyPaintDevice(self)
painter = MyPainter()
painter.begin(pdevice)
painter.setRenderHint(QPainter.Antialiasing)
painter.fillRect(event.rect(), QBrush(QColor("white")))
painter.fillRect(QRect(0, 0, 100, 100), QBrush(QColor("green")))
# tag any following commands as part of the "L1" layer (simulate symbol layer rendering)
painter.setCurrentLayer("L1")
painter.setPen(QPen(QBrush(QColor("black")), 6))
painter.drawLine(0, 0, 20, 20)
painter.drawLine(20, 20, 50, 20)
painter.drawLine(50, 20, 70, 70)
painter.setPen(QPen(QBrush(QColor("yellow")), 3))
painter.drawLine(0, 0, 20, 20)
painter.drawLine(20, 20, 50, 20)
painter.drawLine(50, 20, 70, 70)
# tag any following commands as part of the "L2" layer (~ label "layer")
painter.setCurrentLayer("L2")
mask = QPainterPath()
mask.addRect(40, 25, 40, 20)
painter.drawText(40, 40, "Label")
mask.addRect(0, 5, 50, 20)
painter.drawText(0, 20, "Label2")
# Set the clipping mask for the current layer
painter.setClipMask(mask, ["L1"])
print "=== Painting command FIFO ==="
print "\n".join([repr(x) for x in painter.commands()])
# Actually render
painter.end()
def sizeHint(self):
return QSize(500, 500)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = Window()
window.show()
sys.exit(app.exec_())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment