Hollow Clipping Redux

holesI want to revisit Tuesday’s discussion of clipping; when I wrote that post I was laboring under certain misconceptions related to Core Graphics paths, which I would like to discuss. As a consequence of correcting these misconceptions, I can see that Core Graphics clipping is much easier to work with than I had supposed.

Whoops

My basic mistake was to assume that a Core Graphics path was, essentially, a single closed loop. In fact, a path is a collection of line segments and loops; it is essentially a collection of subpaths, where a subpath corresponds to my original conception of an overall path.

In particular, I misunderstood the purpose of these functions:

I thought that CGContextBeginPath and CGContextClosePath formed a pair, and that a path was defined by Core Graphics calls made between these bookends. In fact, CGContextMoveToPoint and CGContextClosePath form the pair; CGContextMoveToPoint establishes the first point on a subpath to which CGContextClosePath will connect, if it is called.

Once this misapprehension was corrected, some other issues related to clipping became clear.

Winding vs. Non-Zero

I had been puzzled by this language in the Core Graphics documentation for CGContextClip:

The function uses the nonzero winding number rule to calculate the intersection of the current path with the current clipping path. Quartz then uses the path resulting from the intersection as the new current clipping path for subsequent painting operations.

and CGContextEOClip:

The function uses the even-odd rule to calculate the intersection of the current path with the current clipping path. Quartz then uses the path resulting from the intersection as the new current clipping path for subsequent painting operations.

When I was working on clipping to hollow shapes, I first tried this procedure:

  • Define the “outer” hull
  • Invoke CGContext*Clip
  • Define the “inner” hull
  • Invoke CGContext*Clip

However, no matter which CGContext*Clip functions I called at which point, and no matter which winding I used when defining the hulls, I could not get the clipping paths to interact in the way I wanted. I now understand why: These functions effectively apply the “nonzero winding” or “even-odd” rule to the current path (i.e. collection of subpaths), and then take the intersection of the resulting clipping region with the current clipping region.

(Incidentally, the difference between the “nonzero winding” and “even-odd” rules is reasonably well explained here; it’s not Apple documentation, but it’s on-point.)

Examples

This function defines a relatively complex clipping region, used to create the image at the top of this post:

+ (void)setPathToHollowRoundedRect:(CGRect)rect forInset:(NSUInteger)inset withWidth:(NSUInteger)width inContext:(CGContextRef)context
{
	// Experimentally determined
	static const NSUInteger cornerRadius = 10;

	// Unpack size for compactness, find minimum dimension (outer path)
	CGFloat ow = rect.size.width;
	CGFloat oh = rect.size.height;
	CGFloat om = ow<oh?ow:oh;

	// Special case: Degenerate rectangles abort this method
	if (om <= 0) return;

	// Bounds (outer path)
	CGFloat ob = rect.origin.y;
	CGFloat ot = ob + oh;
	CGFloat ol = rect.origin.x;
	CGFloat or = ol + ow;

	// Adjust radius for inset, and limit it to 1/2 of the rectangle's shortest axis (outer path)
	CGFloat oR = (inset<cornerRadius)?(cornerRadius-inset):0;
	oR = (oR>0.5*om)?(0.5*om):oR;

	// Find diameter (and radius) of holes
	CGFloat d = 0.8 * oh;
	CGFloat r = d / 2;

	// Special case: Degenerate rectangles abort this method
	if (3*d > ow) return;

	// Horizontal spacing
	CGFloat s = (ow - 3*d)/4;

	// Begin a new path
	CGContextBeginPath(context);

	// Define a CW path in the CG co-ordinate system (origin at LL)
	CGContextMoveToPoint(context, (ol+or)/2, ot);		// Begin outer loop at TDC
	CGContextAddArcToPoint(context, or, ot, or, ob, oR);	// UR corner
	CGContextAddArcToPoint(context, or, ob, ol, ob, oR);	// LR corner
	CGContextAddArcToPoint(context, ol, ob, ol, ot, oR);	// LL corner
	CGContextAddArcToPoint(context, ol, ot, or, ot, oR);	// UL corner
	CGContextClosePath(context);				// End outer loop at TDC

	// Define 3 CCW paths in the CG co-ordinate system (origin at LL)
	// Note that the final arg must be 0 on both iPhone OS and Mac OS X (including the iPhone simulator)
	// This suggests the documentation is in error
	CGContextMoveToPoint(context, ol+s+2*r, (ob+ot)/2);
	CGContextAddArc(context, ol+s+r, (ob+ot)/2, r, 0, 2*M_PI, 0);

	CGContextMoveToPoint(context, ol+2*s+4*r, (ob+ot)/2);
	CGContextAddArc(context, ol+2*s+3*r, (ob+ot)/2, r, 0, 2*M_PI, 0);

	CGContextMoveToPoint(context, ol+3*s+6*r, (ob+ot)/2);
	CGContextAddArc(context, ol+3*s+5*r, (ob+ot)/2, r, 0, 2*M_PI, 0);
}

Three points:

  • This example was hacked together, so some of the arguments to the function don’t make much sense
  • The documentation for CGContextAddArc states that the final argument (clockwise) must be 1 on the iPhone to create a CCW arc; this does not appear to be true, although I may be misinterpreting my results
  • The CGContextMoveToPoint()s preceding the arcs are crucial; without them, Core Graphics will insert line segments between the last point of one arc and the first point of another, with undesirable results

With our new understanding of paths and clipping, we can simplify the code that we saw on Tuesday:

+ (void)setPathToHollowRoundedRect:(CGRect)rect forInset:(NSUInteger)inset withWidth:(NSUInteger)width inContext:(CGContextRef)context
{
	// Experimentally determined
	static const NSUInteger cornerRadius = 10;

	// Unpack size for compactness, find minimum dimension (outer path)
	CGFloat ow = rect.size.width;
	CGFloat oh = rect.size.height;
	CGFloat om = ow<oh?ow:oh;

	// Special case: Degenerate rectangles abort this method
	if (om <= 0) return;

	// Bounds (outer path)
	CGFloat ob = rect.origin.y;
	CGFloat ot = ob + oh;
	CGFloat ol = rect.origin.x;
	CGFloat or = ol + ow;

	// Adjust radius for inset, and limit it to 1/2 of the rectangle's shortest axis (outer path)
	CGFloat oR = (inset<cornerRadius)?(cornerRadius-inset):0;
	oR = (oR>0.5*om)?(0.5*om):oR;

	// Find minimum dimension of the inner bounding rectangle
	CGFloat im = om - 2*width;

	// Special case: Degenerate rectangles abort this method
	if (im <= 0) return;

	// Bounds (inner path)
	CGFloat ib = ob + width;
	CGFloat it = ot - width;
	CGFloat il = ol + width;
	CGFloat ir = or - width;

	// Adjust inner radius for width
	CGFloat iR = (oR>width)?(oR-width):0;

	// Begin a new path
	CGContextBeginPath(context);

	// Define a CW path in the CG co-ordinate system (origin at LL)
	CGContextMoveToPoint(context, (ol+or)/2, ot);		// Begin outer loop at TDC
	CGContextAddArcToPoint(context, or, ot, or, ob, oR);	// UR corner
	CGContextAddArcToPoint(context, or, ob, ol, ob, oR);	// LR corner
	CGContextAddArcToPoint(context, ol, ob, ol, ot, oR);	// LL corner
	CGContextAddArcToPoint(context, ol, ot, or, ot, oR);	// UL corner
	CGContextClosePath(context);				// End outer loop at TDC

	// Define a CCW path in the CG co-ordinate system (origin at LL)
	CGContextMoveToPoint(context, (il+ir)/2, it);		// Begin inner loop at TDC
	CGContextAddArcToPoint(context, il, it, il, ib, iR);	// UL corner
	CGContextAddArcToPoint(context, il, ib, ir, ib, iR);	// LL corner
	CGContextAddArcToPoint(context, ir, ib, ir, it, iR);	// LR corner
	CGContextAddArcToPoint(context, ir, it, il, it, iR);	// UR corner
	CGContextClosePath(context);				// End inner loop at TDC
}
Share and Enjoy:
  • Twitter
  • Facebook
  • Digg
  • Reddit
  • HackerNews
  • del.icio.us
  • Google Bookmarks
  • Slashdot
This entry was posted in iPhone. Bookmark the permalink.