9

I am offsetting a CGPath using copy(strokingWithWidth:lineCap:lineJoin:miterLimit:transform‌​:). The problem is the offset path introduces all kinds of jagged lines that seem to be the result of a miter join. Changing the miterLimit to 0 has no effect, and using a bevel line join also makes no difference.

In this image there is the original path (before applying strokingWithWidth), an offset path using miter join, and an offset path using bevel join. Why doesn't using bevel join have any affect?

example paths

Code using miter (Note that using CGLineJoin.round produces identical results):

let pathOffset = path.copy(strokingWithWidth: 4.0, 
                           lineCap: CGLineCap.butt,
                           lineJoin: CGLineJoin.miter,
                           miterLimit: 20.0)

context.saveGState()

context.setStrokeColor(UIColor.red.cgColor)
context.addPath(pathOffset)
context.strokePath()

context.restoreGState()

Code using bevel:

let pathOffset = path.copy(strokingWithWidth: 4.0, 
                           lineCap: CGLineCap.butt,
                           lineJoin: CGLineJoin.bevel,
                           miterLimit: 0.0)

context.saveGState()

context.setStrokeColor(UIColor.red.cgColor)
context.addPath(pathOffset)
context.strokePath()

context.restoreGState()
Jeshua Lacock
  • 5,730
  • 1
  • 28
  • 58

1 Answers1

15

Here is a path consisting of two line segments:

core path

Here's what it looks like if I stroke it with bevel joins at a line width of 30:

simple stroke

If I make a stroked copy of the path with the same parameters, the stroked copy looks like this:

stroked copy

Notice that triangle in there? That appears because Core Graphics creates the stroked copy in a simple way: it traces along the each segment of the original path, creating a copied segment that is offset by 15 points. It joins each of these copied segments with straight lines (because I specified bevel joins). In slow motion, the copy operation looks like this:

slow motion copy

So on the inside of the joint, we get a triangle, and on the outside, we get the flat bevel.

When Core Graphics strokes the original path, that triangle is harmless, because Core Graphics uses the non-zero winding rule to fill the stroke. But when you stroke the stroked copy, the triangle becomes visible.

Now, if I scale down the line width used when I make the stroked copy, the triangle becomes smaller. And if I then increase the line width used to draw the stroked copy, and draw the stroked copy with mitered joins, the triangle can actually end up looking like it's filled in:

thinner stroked copy

Now, suppose I replace that single joint in the original path with two joints connected by a very short line, creating a (very small) flat spot on the bottom:

core path with two joints

When I make a stroked copy of this path, the copy has two internal triangles, and if I stroke the stroked copy, it looks like this:

stroked copy of two-joint path

So that's where those weird shapes star shapes come from when you make a stroked copy of your paths: very short segments creating overlapping triangles.

Note that I made my copies with bevel joins. Using miter joins when making the copy also creates the hidden triangles, because the choice of join only affects the outside of the joint, not the inside of the joint.

However, the choice of join does matter when stroking the stroked copy, because the use of miter joins makes the stars larger. See this document for a good illustration of how much the join style can affect the appearance of an acute angle.

So the miter joins make the triangles' points stick out quite far, which makes the overlapping triangles look like a star. Here's the result if I stroke the stroked copy using bevel joins instead:

stroked copy stroked with bevels

The star is nigh-invisible here because the triangles are drawn with blunted corners.

If the inner triangles are unacceptable to you, you will have to write your own function (or find one on the Internet) to make a stroked copy of the path without the triangles, or to eliminate the triangles from the copy.

If your path consists entirely of flat segments, the easiest solution is probably to use an existing polygon-clipping library. The “union” operation, applied to the stroked copy, should eliminate the inner triangles. See this answer for example. Note that these libraries tend to be written in C++, so you'll probably have to write some Objective-C++ code since Swift cannot call C++ code directly.

In case you're wondering how I generated the graphics for this answer, I did it using this Swift playground.

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • Thanks for the comprehensive answer. The last line you draw looks completely acceptable using bevel joints. Only - my example looks identical using bevel joints as it does using miter joints so I guess I will have to write something or give up on using this line style I was hoping to use... – Jeshua Lacock Jul 17 '17 at 07:27
  • 1
    I don't see anything in the code you posted that sets the line join style before stroking the copied path. Do you have code like `context.setLineJoin(.bevel)` somewhere? – rob mayoff Jul 17 '17 at 08:20
  • Doh! You're right of course! That helped quite a bit, but because the bends in my sample are more severe than your line, the "triangle" folds. Still - might be good enough for my purposes since I don't expect the lines to be as twisty as my example. Thank you! – Jeshua Lacock Jul 17 '17 at 09:26
  • 1
    Rob, thanks for the clear and detailed answer. I'm working on animating changes to blurred non-filled paths, and to do that I'm using copy(strokingWithWidth:lineCap:lineJoin:miterLimit:transform‌​:) to create a filled path out of an outline path, and then installing that filled path as the shadowPath of a layer. Those triangle artifacts wreak havoc on path animation as the path changes shape, since the number of control points in the filled path changes as the shape changes, and those artifacts really confuse path animation. – Duncan C Sep 10 '20 at 14:30
  • I've thought about writing a smarter version of the stroked-to-filled-path conversion that would detect the intersections of the inside corners and remove the sprurious points (you'd check each pair of line segments for an intersection and if you found one, use that instead of the endpoints.) It would be a lot of work. I wonder if anybody has written such a function already? – Duncan C Sep 10 '20 at 14:31
  • @robmayoff it occurs to me that always tracing the outline of a stroked path by using the intersection of the outer lines would be a simple way to both avoid the triangle artifacts you describe and avoid paths with variable numbers of points (which plays havoc with path animation.) Do you know of any libraries that will create an online (like `copy(strokingWithWidth:lineCap:lineJoin:miterLimit:transform‌​:)`) but using intersections to connect the outlines? If I can't find one, I may have to write it. – Duncan C Sep 11 '20 at 13:03