diff --git a/Lib/blackrenderer/backends/cairo.py b/Lib/blackrenderer/backends/cairo.py index 1383105..208cfe4 100644 --- a/Lib/blackrenderer/backends/cairo.py +++ b/Lib/blackrenderer/backends/cairo.py @@ -1,11 +1,12 @@ from contextlib import contextmanager import os +from math import sqrt from fontTools.pens.basePen import BasePen from fontTools.pens.recordingPen import RecordingPen from fontTools.ttLib.tables.otTables import ExtendMode import cairo from .base import Canvas, Surface - +from .sweepGradient import buildSweepGradientPatches _extendModeMap = { ExtendMode.PAD: cairo.Extend.PAD, @@ -106,9 +107,32 @@ def drawPathSweepGradient( extendMode, gradientTransform, ): - self.drawPathSolid(path, colorLine[0][1]) - - # TODO: blendMode for PaintComposite + # alloc the mesh pattern + pat = cairo.MeshPattern() + # find current path' extent + x1, y1, x2, y2 = self.context.clip_extents() + maxX = max(d * d for d in (x1 - center[0], x2 - center[0])) + maxY = max(d * d for d in (y1 - center[1], y2 - center[1])) + R = sqrt(maxX + maxY) + patches = buildSweepGradientPatches( + colorLine, center, R, startAngle, endAngle, useGouraudShading=False + ) + for (P0, color0), C0, C1, (P1, color1) in patches: + # draw patch + pat.begin_patch() + pat.move_to(center[0], center[1]) + pat.line_to(P0[0], P0[1]) + pat.curve_to(C0[0], C0[1], C1[0], C1[1], P1[0], P1[1]) + pat.line_to(center[0], center[1]) + pat.set_corner_color_rgba(0, *color0) + pat.set_corner_color_rgba(1, *color0) + pat.set_corner_color_rgba(2, *color1) + pat.set_corner_color_rgba(3, *color1) + pat.end_patch() + self.context.set_source(pat) + self.context.paint() + + # TODO: blendMode for PaintComposite) def _drawGradient(self, path, gradient, gradientTransform): self.context.new_path() diff --git a/Lib/blackrenderer/backends/coregraphics.py b/Lib/blackrenderer/backends/coregraphics.py index 08cf220..921fa55 100644 --- a/Lib/blackrenderer/backends/coregraphics.py +++ b/Lib/blackrenderer/backends/coregraphics.py @@ -4,6 +4,7 @@ from fontTools.ttLib.tables.otTables import ExtendMode import Quartz as CG from .base import Canvas, Surface +from .sweepGradient import buildSweepGradientPatches class CoreGraphicsPathPen(BasePen): @@ -125,7 +126,35 @@ def drawPathSweepGradient( extendMode, gradientTransform, ): - self.drawPathSolid(path, colorLine[0][1]) + from math import sqrt + + if self.clipIsEmpty or CG.CGPathGetBoundingBox(path.path) == CG.CGRectNull: + return + with self.savedState(): + CG.CGContextAddPath(self.context, path.path) + CG.CGContextClip(self.context) + self.transform(gradientTransform) + # find current path' extent + bb = CG.CGContextGetClipBoundingBox(self.context) + x1, y1 = bb.origin.x, bb.origin.y + x2 = x1 + bb.size.width + y2 = y1 + bb.size.height + maxX = max(d * d for d in (x1 - center[0], x2 - center[0])) + maxY = max(d * d for d in (y1 - center[1], y2 - center[1])) + R = sqrt(maxX + maxY) + # compute the triangle fan approximating the sweep gradient + patches = buildSweepGradientPatches( + colorLine, center, R, startAngle, endAngle, useGouraudShading=True + ) + CG.CGContextSetAllowsAntialiasing(self.context, False) + for (P0, color0), (P1, color1) in patches: + color = 0.5 * (color0 + color1) + CG.CGContextMoveToPoint(self.context, center[0], center[1]) + CG.CGContextAddLineToPoint(self.context, P0[0], P0[1]) + CG.CGContextAddLineToPoint(self.context, P1[0], P1[1]) + CG.CGContextSetRGBFillColor(self.context, *color) + CG.CGContextFillPath(self.context) + CG.CGContextSetAllowsAntialiasing(self.context, True) # TODO: blendMode for PaintComposite diff --git a/Lib/blackrenderer/backends/sweepGradient.py b/Lib/blackrenderer/backends/sweepGradient.py new file mode 100644 index 0000000..adb8223 --- /dev/null +++ b/Lib/blackrenderer/backends/sweepGradient.py @@ -0,0 +1,80 @@ +from math import pi, ceil, sin, cos, radians +from fontTools.misc.vector import Vector + + +def buildSweepGradientPatches( + colorLine, + center, + radius, + startAngle, + endAngle, + useGouraudShading, + maxAngle=None, +): + """Provides colorful triangular patches that mimic a sweep gradient. + + Together the patches approximate an angular section of a disk of center + 'center' and radius 'radius'. + The patches respect the color line provided by 'colorLine'. + The angular section is beetween 'startAngle' and 'endAngle'. + + For use, in particular, in the Cairo and CoreGraphics backends, since these + libraries lack the sweep gradient feature. + + useGouraudShading -- If True, build a lot of skinny triangles, expected to + be constant or Gouraud shaded. If False, build fewer degenerate Coons patches + (outer boundary is rounded), expected to be used in a "mesh gradient" + + Optional keyword arguments: + maxAngle -- largest desired angular extent of a single triangular patch.""" + patches = [] + # generate a fan of 'triangular' bezier patches, with center 'center' and radius 'radius' + if maxAngle is None: + if useGouraudShading: + maxAngle = pi / 360.0 + else: + maxAngle = pi / 8.0 + else: + maxAngle = max(min(maxAngle, pi / 2), pi / 360) + if useGouraudShading: + # Use a slightly larger radius to make sure that disk with the original + # radius completely fits within the straight-edged triangles that we + # will generate + radius = radius / cos(maxAngle / 2) + n = len(colorLine) + center = Vector(center) + for i in range(n - 1): + a0, col0 = colorLine[i + 0] + a1, col1 = colorLine[i + 1] + if a0 == a1: + continue # two equal stopOffset are used to add color discontinuities. Nothing too draw + col0 = Vector(col0) + col1 = Vector(col1) + a0 = radians(startAngle + a0 * (endAngle - startAngle)) + a1 = radians(startAngle + a1 * (endAngle - startAngle)) + numSplits = int(ceil((a1 - a0) / maxAngle)) + p0 = Vector((cos(a0), sin(a0))) + color0 = col0 + for a in range(numSplits): + k = (a + 1.0) / numSplits + angle1 = a0 + k * (a1 - a0) + color1 = col0 + k * (col1 - col0) + p1 = Vector((cos(angle1), sin(angle1))) + P0 = center[0] + radius * p0[0], center[1] + radius * p0[1] + P1 = center[0] + radius * p1[0], center[1] + radius * p1[1] + # draw patch + if useGouraudShading: + patches.append(((P0, color0), (P1, color1))) + else: + # compute cubic Bezier antennas (control points) so as to approximate the circular arc p0-p1 + A = (p0 + p1).normalized() + U = Vector((-A[1], A[0])) # tangent to circle at A + C0 = A + ((p0 - A).dot(p0) / U.dot(p0)) * U + C1 = A + ((p1 - A).dot(p1) / U.dot(p1)) * U + C0 = center + radius * (C0 + 0.33333 * (C0 - p0)) + C1 = center + radius * (C1 + 0.33333 * (C1 - p1)) + patches.append(((P0, color0), C0, C1, (P1, color1))) + # move to next patch + p0 = p1 + color0 = color1 + return patches diff --git a/Tests/test_canvas_api.py b/Tests/test_canvas_api.py index bacf86e..6c06284 100644 --- a/Tests/test_canvas_api.py +++ b/Tests/test_canvas_api.py @@ -54,18 +54,23 @@ def test_colorStops(backendName, surfaceFactory, stopOffsets, extend): @pytest.mark.parametrize("extend", test_extendModes) @pytest.mark.parametrize("backendName, surfaceFactory", backends) def test_sweepGradient(backendName, surfaceFactory, extend): - surface = surfaceFactory(0, 0, 200, 200) + H, W = 400, 400 + surface = surfaceFactory(0, 0, H, W) canvas = surface.canvas - center = (100, 100) + center = (H / 2, W / 2) startAngle = 45 endAngle = 315 - color1 = (1, 0, 0, 1) - color2 = (0, 0, 1, 1) - stopOffsets = [0, 1] - stop1, stop2 = stopOffsets - colorLine = [(stop1, color1), (stop2, color2)] + colors = [ + (1, 0, 0, 1), + (0, 1, 0, 1), + (1, 1, 0, 1), + (1, 0.5, 1, 1), + (0, 0, 1, 1), + ] + stopOffsets = [0, 0.5, 0.5, 0.6, 1] + colorLine = list(zip(stopOffsets, colors)) canvas.drawRectSweepGradient( - (0, 0, 200, 200), colorLine, center, startAngle, endAngle, extend, Identity + (0, 0, H, W), colorLine, center, startAngle, endAngle, extend, Identity ) ext = surface.fileExtension