diff --git a/src/pixie/paths.nim b/src/pixie/paths.nim index c7c5233..9d2c16d 100644 --- a/src/pixie/paths.nim +++ b/src/pixie/paths.nim @@ -31,6 +31,8 @@ type SomePath* = Path | string | seq[seq[Vec2]] +const epsilon = 0.0001 * PI + when defined(release): {.push checks: off.} @@ -972,11 +974,10 @@ template computeCoverages( windingRule: WindingRule ) = const - ep = 0.0001 * PI quality = 5 # Must divide 255 cleanly (1, 3, 5, 15, 17, 51, 85) sampleCoverage = (255 div quality).uint8 offset = 1 / quality.float32 - initialOffset = offset / 2 + ep + initialOffset = offset / 2 + epsilon let partition = @@ -1233,43 +1234,122 @@ proc fillShapes( proc strokeShapes( shapes: seq[seq[Vec2]], - strokeWidth: float32 + strokeWidth: float32, + lineCap: LineCap, + lineJoin: LineJoin, + miterAngleLimit = degToRad(28.96) ): seq[seq[Vec2]] = if strokeWidth == 0: return - let - widthLeft = strokeWidth / 2 - widthRight = strokeWidth / 2 + let halfStroke = strokeWidth / 2 + + proc makeCircle(at: Vec2): seq[Vec2] = + var path: Path + path.ellipse(at, halfStroke, halfStroke) + path.commandsToShapes()[0] + + proc makeRect(at, to: Vec2): seq[Vec2] = + # Rectangle corners + let + tangent = (to - at).normalize() + normal = vec2(tangent.y, tangent.x) + a = vec2( + at.x + normal.x * halfStroke, + at.y - normal.y * halfStroke + ) + b = vec2( + to.x + normal.x * halfStroke, + to.y - normal.y * halfStroke + ) + c = vec2( + to.x - normal.x * halfStroke, + to.y + normal.y * halfStroke + ) + d = vec2( + at.x - normal.x * halfStroke, + at.y + normal.y * halfStroke + ) + + @[a, b, c, d, a] for shape in shapes: - var - strokeShape: seq[Vec2] - back: seq[Vec2] - for segment in shape.segments: + var shapeStroke: seq[seq[Vec2]] + + proc makeJoin(prevPos, pos, nextPos: Vec2): seq[Vec2] = + let angle = fixAngle(-angle(prevPos - pos) - -angle(nextPos - pos)) + if abs(abs(angle) - PI) > epsilon: + var + a = (pos - prevPos).normalize() * halfStroke + b = (pos - nextPos).normalize() * halfStroke + if angle >= 0: + a = vec2(-a.y, a.x) + b = vec2(b.y, -b.x) + else: + a = vec2(a.y, -a.x) + b = vec2(-b.y, b.x) + + var lineJoin = lineJoin + if lineJoin == ljMiter and abs(angle) < miterAngleLimit: + lineJoin = ljBevel + + case lineJoin: + of ljMiter: + let + la = line(prevPos + a, pos + a) + lb = line(nextPos + b, pos + b) + var at: Vec2 + if la.intersects(lb, at): + return @[pos + a, at, pos + b, pos, pos + a] + + of ljBevel: + return @[a + pos, b + pos, pos, a + pos] + + of ljRound: + return makeCircle(prevPos) + + if shape[0] != shape[^1]: + # This shape does not end at the same point it starts so draw the + # first line cap. + case lineCap: + of lcButt: + discard + of lcRound: + shapeStroke.add(makeCircle(shape[0])) + of lcSquare: + let tangent = (shape[1] - shape[0]).normalize() + shapeStroke.add(makeRect( + shape[0] - tangent * halfStroke, + shape[0] + )) + + for i in 1 ..< shape.len: let - tangent = (segment.at - segment.to).normalize() - normal = vec2(-tangent.y, tangent.x) - left = segment( - segment.at - normal * widthLeft, - segment.to - normal * widthLeft - ) - right = segment( - segment.at + normal * widthRight, - segment.to + normal * widthRight - ) + pos = shape[i] + prevPos = shape[i - 1] - strokeShape.add([right.at, right.to]) - back.add([left.at, left.to]) + shapeStroke.add(makeRect(prevPos, pos)) - # Add the back side reversed - for i in 1 .. back.len: - strokeShape.add(back[^i]) + # If we need a line join + if i < shape.len - 1: + shapeStroke.add(makeJoin(prevPos, pos, shape[i + 1])) - strokeShape.add(strokeShape[0]) + if shape[0] == shape[^1]: + shapeStroke.add(makeJoin(shape[^2], shape[^1], shape[1])) + else: + case lineCap: + of lcButt: + discard + of lcRound: + shapeStroke.add(makeCircle(shape[^1])) + of lcSquare: + let tangent = (shape[^1] - shape[^2]).normalize() + shapeStroke.add(makeRect( + shape[^1] + tangent * halfStroke, + shape[^1] + )) - if strokeShape.len > 0: - result.add(strokeShape) + result.add(shapeStroke) proc parseSomePath( path: SomePath, pixelScale: float32 = 1.0 @@ -1338,9 +1418,13 @@ proc strokePath*( path: SomePath, color: ColorRGBA, strokeWidth = 1.0, + lineCap = lcButt, + lineJoin = ljMiter, blendMode = bmNormal ) = - let strokeShapes = strokeShapes(parseSomePath(path), strokeWidth) + let strokeShapes = strokeShapes( + parseSomePath(path), strokeWidth, lineCap, lineJoin + ) image.fillShapes(strokeShapes, color, wrNonZero, blendMode) proc strokePath*( @@ -1349,13 +1433,17 @@ proc strokePath*( color: ColorRGBA, transform: Vec2 | Mat3, strokeWidth = 1.0, + lineCap = lcButt, + lineJoin = ljMiter, blendMode = bmNormal ) = when type(transform) is Mat3: let pixelScale = transform.maxScale() else: let pixelScale = 1.0 - var strokeShapes = strokeShapes(parseSomePath(path, pixelScale), strokeWidth) + var strokeShapes = strokeShapes( + parseSomePath(path, pixelScale), strokeWidth, lineCap, lineJoin + ) for shape in strokeShapes.mitems: for segment in shape.mitems: when type(transform) is Vec2: @@ -1367,18 +1455,26 @@ proc strokePath*( proc strokePath*( mask: Mask, path: SomePath, - strokeWidth = 1.0 + strokeWidth = 1.0, + lineCap = lcButt, + lineJoin = ljMiter ) = - let strokeShapes = strokeShapes(parseSomePath(path), strokeWidth) + let strokeShapes = strokeShapes( + parseSomePath(path), strokeWidth, lineCap, lineJoin + ) mask.fillShapes(strokeShapes, wrNonZero) proc strokePath*( mask: Mask, path: SomePath, + transform: Vec2 | Mat3, strokeWidth = 1.0, - transform: Vec2 | Mat3 + lineCap = lcButt, + lineJoin = ljMiter ) = - var strokeShapes = strokeShapes(parseSomePath(path), strokeWidth) + var strokeShapes = strokeShapes( + parseSomePath(path), strokeWidth, lineCap, lineJoin + ) for shape in strokeShapes.mitems: for segment in shape.mitems: when type(transform) is Vec2: diff --git a/tests/images/masks/strokeEllipse.png b/tests/images/masks/strokeEllipse.png index 872ae6f..2f43cff 100644 Binary files a/tests/images/masks/strokeEllipse.png and b/tests/images/masks/strokeEllipse.png differ diff --git a/tests/images/masks/strokePolygon.png b/tests/images/masks/strokePolygon.png index 9682590..201b9a7 100644 Binary files a/tests/images/masks/strokePolygon.png and b/tests/images/masks/strokePolygon.png differ diff --git a/tests/images/masks/strokeRect.png b/tests/images/masks/strokeRect.png index c44294a..b663c6e 100644 Binary files a/tests/images/masks/strokeRect.png and b/tests/images/masks/strokeRect.png differ diff --git a/tests/images/masks/strokeRoundedRect.png b/tests/images/masks/strokeRoundedRect.png index 73a2437..9eeed39 100644 Binary files a/tests/images/masks/strokeRoundedRect.png and b/tests/images/masks/strokeRoundedRect.png differ diff --git a/tests/images/paths/boxBevel.png b/tests/images/paths/boxBevel.png new file mode 100644 index 0000000..814a7e1 Binary files /dev/null and b/tests/images/paths/boxBevel.png differ diff --git a/tests/images/paths/boxMiter.png b/tests/images/paths/boxMiter.png new file mode 100644 index 0000000..69b02a3 Binary files /dev/null and b/tests/images/paths/boxMiter.png differ diff --git a/tests/images/paths/boxRound.png b/tests/images/paths/boxRound.png new file mode 100644 index 0000000..57e6a2f Binary files /dev/null and b/tests/images/paths/boxRound.png differ diff --git a/tests/images/paths/lcButt.png b/tests/images/paths/lcButt.png new file mode 100644 index 0000000..f7997bc Binary files /dev/null and b/tests/images/paths/lcButt.png differ diff --git a/tests/images/paths/lcRound.png b/tests/images/paths/lcRound.png new file mode 100644 index 0000000..7bb248f Binary files /dev/null and b/tests/images/paths/lcRound.png differ diff --git a/tests/images/paths/lcSquare.png b/tests/images/paths/lcSquare.png new file mode 100644 index 0000000..5f2a5d8 Binary files /dev/null and b/tests/images/paths/lcSquare.png differ diff --git a/tests/images/paths/pathStroke2.png b/tests/images/paths/pathStroke2.png index 33bca69..5d0eeb0 100644 Binary files a/tests/images/paths/pathStroke2.png and b/tests/images/paths/pathStroke2.png differ diff --git a/tests/images/paths/pathStroke3.png b/tests/images/paths/pathStroke3.png index 7e7aeeb..ace42f8 100644 Binary files a/tests/images/paths/pathStroke3.png and b/tests/images/paths/pathStroke3.png differ diff --git a/tests/images/paths/pixelScale.png b/tests/images/paths/pixelScale.png index 82996d4..d368ad9 100644 Binary files a/tests/images/paths/pixelScale.png and b/tests/images/paths/pixelScale.png differ diff --git a/tests/images/strokeEllipse.png b/tests/images/strokeEllipse.png index 828dd58..d21030b 100644 Binary files a/tests/images/strokeEllipse.png and b/tests/images/strokeEllipse.png differ diff --git a/tests/images/strokePolygon.png b/tests/images/strokePolygon.png index f4483df..3812fd8 100644 Binary files a/tests/images/strokePolygon.png and b/tests/images/strokePolygon.png differ diff --git a/tests/images/strokeRect.png b/tests/images/strokeRect.png index 3d8128a..fb80260 100644 Binary files a/tests/images/strokeRect.png and b/tests/images/strokeRect.png differ diff --git a/tests/images/strokeRoundedRect.png b/tests/images/strokeRoundedRect.png index d1745c6..a6e3e8c 100644 Binary files a/tests/images/strokeRoundedRect.png and b/tests/images/strokeRoundedRect.png differ diff --git a/tests/images/svg/Ghostscript_Tiger.png b/tests/images/svg/Ghostscript_Tiger.png index e4132d4..7ac92d7 100644 Binary files a/tests/images/svg/Ghostscript_Tiger.png and b/tests/images/svg/Ghostscript_Tiger.png differ diff --git a/tests/images/svg/circle01.png b/tests/images/svg/circle01.png index 0679ead..78e8298 100644 Binary files a/tests/images/svg/circle01.png and b/tests/images/svg/circle01.png differ diff --git a/tests/images/svg/ellipse01.png b/tests/images/svg/ellipse01.png index 137a435..69f6f27 100644 Binary files a/tests/images/svg/ellipse01.png and b/tests/images/svg/ellipse01.png differ diff --git a/tests/images/svg/line01.png b/tests/images/svg/line01.png index b9a619b..f00b96f 100644 Binary files a/tests/images/svg/line01.png and b/tests/images/svg/line01.png differ diff --git a/tests/images/svg/polygon01.png b/tests/images/svg/polygon01.png index 5338183..bda6fc6 100644 Binary files a/tests/images/svg/polygon01.png and b/tests/images/svg/polygon01.png differ diff --git a/tests/images/svg/polyline01.png b/tests/images/svg/polyline01.png index e8aff48..77c3e57 100644 Binary files a/tests/images/svg/polyline01.png and b/tests/images/svg/polyline01.png differ diff --git a/tests/images/svg/quad01.png b/tests/images/svg/quad01.png index 349dbbd..6844ce3 100644 Binary files a/tests/images/svg/quad01.png and b/tests/images/svg/quad01.png differ diff --git a/tests/images/svg/rect01.png b/tests/images/svg/rect01.png index c35e73d..250f7ef 100644 Binary files a/tests/images/svg/rect01.png and b/tests/images/svg/rect01.png differ diff --git a/tests/images/svg/rect02.png b/tests/images/svg/rect02.png index 089b562..96aea6b 100644 Binary files a/tests/images/svg/rect02.png and b/tests/images/svg/rect02.png differ diff --git a/tests/images/svg/triangle01.png b/tests/images/svg/triangle01.png index fa38fb8..90d742d 100644 Binary files a/tests/images/svg/triangle01.png and b/tests/images/svg/triangle01.png differ diff --git a/tests/test_paths.nim b/tests/test_paths.nim index 7018717..3a2dab0 100644 --- a/tests/test_paths.nim +++ b/tests/test_paths.nim @@ -214,3 +214,57 @@ block: strokeWidth = 0.01) image.writeFile("tests/images/paths/pixelScale.png") + +block: + let + image = newImage(60, 60) + path = parsePath("M 3 3 L 20 3 L 20 20 L 3 20 Z") + image.fill(rgba(255, 255, 255, 255)) + image.strokePath(path, rgba(0, 0, 0, 255), vec2(10, 10), 10, lcRound, ljRound) + + image.writeFile("tests/images/paths/boxRound.png") + +block: + let + image = newImage(60, 60) + path = parsePath("M 3 3 L 20 3 L 20 20 L 3 20 Z") + image.fill(rgba(255, 255, 255, 255)) + image.strokePath(path, rgba(0, 0, 0, 255), vec2(10, 10), 10, lcRound, ljBevel) + + image.writeFile("tests/images/paths/boxBevel.png") + +block: + let + image = newImage(60, 60) + path = parsePath("M 3 3 L 20 3 L 20 20 L 3 20 Z") + image.fill(rgba(255, 255, 255, 255)) + image.strokePath(path, rgba(0, 0, 0, 255), vec2(10, 10), 10, lcRound, ljMiter) + + image.writeFile("tests/images/paths/boxMiter.png") + +block: + let + image = newImage(60, 60) + path = parsePath("M 3 3 L 20 3 L 20 20 L 3 20") + image.fill(rgba(255, 255, 255, 255)) + image.strokePath(path, rgba(0, 0, 0, 255), vec2(10, 10), 10, lcButt, ljBevel) + + image.writeFile("tests/images/paths/lcButt.png") + +block: + let + image = newImage(60, 60) + path = parsePath("M 3 3 L 20 3 L 20 20 L 3 20") + image.fill(rgba(255, 255, 255, 255)) + image.strokePath(path, rgba(0, 0, 0, 255), vec2(10, 10), 10, lcRound, ljBevel) + + image.writeFile("tests/images/paths/lcRound.png") + +block: + let + image = newImage(60, 60) + path = parsePath("M 3 3 L 20 3 L 20 20 L 3 20") + image.fill(rgba(255, 255, 255, 255)) + image.strokePath(path, rgba(0, 0, 0, 255), vec2(10, 10), 10, lcSquare, ljBevel) + + image.writeFile("tests/images/paths/lcSquare.png")