Created
June 29, 2016 13:03
-
-
Save mhugo/fbfa5d15b95787d20f5e8f7863441036 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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