diff --git a/src/pixie/fileformats/svg.nim b/src/pixie/fileformats/svg.nim index 047566ee..1245dec7 100644 --- a/src/pixie/fileformats/svg.nim +++ b/src/pixie/fileformats/svg.nim @@ -14,18 +14,22 @@ type Svg* = ref object width*, height*: int elements: seq[(Path, SvgProperties)] + prepared: bool linearGradients: Table[string, LinearGradient] SvgProperties = object display: bool fillRule: WindingRule fill: string + fillColor: ColorRGBX + fillPrepared: PreparedShapes stroke: ColorRGBX strokeWidth: float32 strokeLineCap: LineCap strokeLineJoin: LineJoin strokeMiterLimit: float32 strokeDashArray: seq[float32] + strokePrepared: PreparedShapes transform: Mat3 opacity, fillOpacity, strokeOpacity: float32 @@ -554,8 +558,50 @@ proc parseSvg*(data: string, width = 0, height = 0): Svg {.raises: [PixieError]. except: raise currentExceptionAsPixieError() +proc prepareDraws(svg: Svg) {.raises: [PixieError].} = + ## Tessellates SVG paths once so repeated renders reuse polygons. + if svg.prepared: + return + + try: + for i in 0 ..< svg.elements.len: + let + path = svg.elements[i][0] + props = svg.elements[i][1].addr + if not props.display or props.opacity <= 0: + continue + + if props.fill != "none" and not props.fill.startsWith("url("): + props.fillColor = + parseHtmlColor(props.fill).rgbx * (props.fillOpacity * props.opacity) + if props.fillColor.a > 0: + props.fillPrepared = path.polygonsForFill(props.transform).prepareShapes( + svg.width, svg.height + ) + + if props.stroke != rgbx(0, 0, 0, 0) and props.strokeWidth > 0: + let strokeColor = + props.stroke * (props.opacity * props.strokeOpacity) + if strokeColor.a > 0: + props.stroke = strokeColor + props.strokePrepared = path.polygonsForStroke( + props.transform, + props.strokeWidth, + props.strokeLineCap, + props.strokeLineJoin, + props.strokeMiterLimit, + props.strokeDashArray + ).prepareShapes(svg.width, svg.height) + except PixieError as e: + raise e + except: + raise currentExceptionAsPixieError() + + svg.prepared = true + proc newImage*(svg: Svg): Image {.raises: [PixieError].} = ## Render SVG and return the image. + svg.prepareDraws() result = newImage(svg.width, svg.height) try: @@ -563,7 +609,6 @@ proc newImage*(svg: Svg): Image {.raises: [PixieError].} = for (path, props) in svg.elements: if props.display and props.opacity > 0: if props.fill != "none": - var paint: Paint if props.fill.startsWith("url("): let closingParen = props.fill.find(")", 5) if closingParen == -1: @@ -571,36 +616,33 @@ proc newImage*(svg: Svg): Image {.raises: [PixieError].} = let id = props.fill[5 .. closingParen - 1] if id in svg.linearGradients: let linearGradient = svg.linearGradients[id] - paint = newPaint(LinearGradientPaint) + var paint = newPaint(LinearGradientPaint) paint.gradientHandlePositions = @[ props.transform * vec2(linearGradient.x1, linearGradient.y1), props.transform * vec2(linearGradient.x2, linearGradient.y2) ] paint.gradientStops = linearGradient.stops + paint.opacity = props.fillOpacity * props.opacity + paint.blendMode = blendMode + result.fillPath(path, paint, props.transform, props.fillRule) else: raise newException(PixieError, "Missing SVG resource " & id) - else: - paint = parseHtmlColor(props.fill).rgbx - - paint.opacity = props.fillOpacity * props.opacity - paint.blendMode = blendMode - - result.fillPath(path, paint, props.transform, props.fillRule) + elif props.fillPrepared != nil: + result.fillPreparedShapes( + props.fillPrepared, + props.fillColor, + props.fillRule, + blendMode + ) blendMode = NormalBlend # Switch to normal when compositing multiple paths - if props.stroke != rgbx(0, 0, 0, 0) and props.strokeWidth > 0: - let paint = props.stroke.copy() - paint.color.a *= (props.opacity * props.strokeOpacity) - result.strokePath( - path, - paint, - props.transform, - props.strokeWidth, - props.strokeLineCap, - props.strokeLineJoin, - miterLimit = props.strokeMiterLimit, - dashes = props.strokeDashArray + if props.strokePrepared != nil: + result.fillPreparedShapes( + props.strokePrepared, + props.stroke, + NonZero, + blendMode ) except PixieError as e: raise e diff --git a/src/pixie/paths.nim b/src/pixie/paths.nim index b0aa3d02..49411a63 100644 --- a/src/pixie/paths.nim +++ b/src/pixie/paths.nim @@ -38,6 +38,12 @@ type requiresAntiAliasing, twoNonintersectingSpanningSegments: bool top, bottom: int + PreparedShapes* = ref object + ## Cached scanline partitions for repeated solid fills of the same shapes. + partitions: seq[Partition] + startX*, startY*, pathWidth*, pathHeight*: int + maxEntryCount: int + Fixed32 = int32 ## 24.8 fixed point const @@ -1261,7 +1267,7 @@ proc partitionSegments( if entry0.midpointX > entry1.midpointX: swap partition.entries[1], partition.entries[0] -proc maxEntryCount(partitions: var seq[Partition]): int = +proc maxEntryCount(partitions: seq[Partition]): int = for i in 0 ..< partitions.len: result = max(result, partitions[i].entries.len) @@ -1353,7 +1359,7 @@ proc computeCoverage( numHits: var int, width: int, y, startX: int, - partitions: var seq[Partition], + partitions: seq[Partition], partitionIndex: int, entryIndices: seq[int], numEntryIndices: int, @@ -1590,38 +1596,75 @@ proc fillHits( image.data[dataIndex] = blender(backdrop, rgbx) inc dataIndex -proc fillShapes( +proc prepareShapes*( + shapes: seq[Polygon], + imageWidth, imageHeight: int +): PreparedShapes {.raises: [PixieError].} = + ## Builds cached partitions for shapes in image space. + result = PreparedShapes() + let + segments = shapes.shapesToSegments() + bounds = computeBounds(segments).snapToPixels() + result.startX = max(0, bounds.x.int) + result.startY = max(0, bounds.y.int) + result.pathWidth = + if result.startX < imageWidth: + min(bounds.w.int, imageWidth - result.startX) + else: + 0 + result.pathHeight = min(imageHeight, (bounds.y + bounds.h).int) + + if result.pathWidth == 0: + return + + if result.pathWidth < 0: + raise newException(PixieError, "Path int overflow detected") + + result.partitions = partitionSegments( + segments, + result.startY, + result.pathHeight - result.startY + ) + result.maxEntryCount = result.partitions.maxEntryCount + +proc fillShapes*( image: Image, shapes: seq[Polygon], color: SomeColor, windingRule: WindingRule, blendMode: BlendMode ) = + image.fillPreparedShapes( + shapes.prepareShapes(image.width, image.height), + color, + windingRule, + blendMode + ) + +proc fillPreparedShapes*( + image: Image, + prepared: PreparedShapes, + color: SomeColor, + windingRule: WindingRule, + blendMode: BlendMode +) = + ## Fills previously prepared shapes. # Figure out the total bounds of all the shapes, # rasterize only within the total bounds let rgbx = color.asRgbx() - segments = shapes.shapesToSegments() - bounds = computeBounds(segments).snapToPixels() - startX = max(0, bounds.x.int) - startY = max(0, bounds.y.int) - pathWidth = - if startX < image.width: - min(bounds.w.int, image.width - startX) - else: - 0 - pathHeight = min(image.height, (bounds.y + bounds.h).int) + startX = prepared.startX + startY = prepared.startY + pathWidth = prepared.pathWidth + pathHeight = prepared.pathHeight if pathWidth == 0: return - if pathWidth < 0: - raise newException(PixieError, "Path int overflow detected") - + let partitions = prepared.partitions var - partitions = partitionSegments(segments, startY, pathHeight - startY) partitionIndex: int - entryIndices = newSeq[int](partitions.maxEntryCount) + entryIndices = newSeq[int](prepared.maxEntryCount) numEntryIndices: int trapezoidSegments = newSeq[Segment](entryIndices.len) coverages = newSeq[uint8](pathWidth) @@ -2090,6 +2133,34 @@ proc parseSomePath( elif type(path) is Path: path.commandsToShapes(closeSubpaths, pixelScale) +proc polygonsForFill*( + path: Path, transform = mat3() +): seq[Polygon] {.raises: [PixieError].} = + ## Tessellates a path into fill polygons in transform space. + result = parseSomePath(path, true, transform.pixelScale()) + result.transform(transform) + +proc polygonsForStroke*( + path: Path, + transform = mat3(), + strokeWidth: float32 = 1.0, + lineCap = ButtCap, + lineJoin = MiterJoin, + miterLimit = defaultMiterLimit, + dashes: seq[float32] = @[] +): seq[Polygon] {.raises: [PixieError].} = + ## Tessellates a stroked path into fill polygons in transform space. + result = strokeShapes( + parseSomePath(path, false, transform.pixelScale()), + strokeWidth, + lineCap, + lineJoin, + miterLimit, + dashes, + pixelScale(transform) + ) + result.transform(transform) + proc fillPath*( image: Image, path: SomePath,