How do the non-zero and even-odd fill rules decide what is inside a path, and what does contains(_:) ignore?

9.11.2 · L3 · Interview

Predict first

Is the centre of a nested square filled under each rule, and does a line contain a point on it?

import UIKit

let outer = CGRect(x: 0, y: 0, width: 100, height: 100), inner = CGRect(x: 25, y: 25, width: 50, height: 50)
let center = CGPoint(x: 50, y: 50)
let path = UIBezierPath(rect: outer); path.append(UIBezierPath(rect: inner))
print(path.contains(center))
path.usesEvenOddFillRule = true; print(path.contains(center))
let hole = UIBezierPath(rect: outer); hole.append(UIBezierPath(rect: inner).reversing())
print(hole.contains(center))
let line = UIBezierPath(); line.move(to: .zero); line.addLine(to: CGPoint(x: 100, y: 0)); line.lineWidth = 10
print(line.contains(CGPoint(x: 50, y: 2)), line.bounds.height, line.cgPath.copy(strokingWithWidth: 10, lineCap: .butt, lineJoin: .miter, miterLimit: 10).boundingBox.height)
  • true, false, false, then false 0.0 10.0
  • false, true, true, then true 10.0 10.0
  • true, true, false, then false 0.0 0.0

Mental model. Non-zero counts how many times you wind around a point; even-odd just counts fences between the point and the outside.

For a path with overlapping or nested sub-paths, “inside” needs a rule. Non-zero winding (the default) draws a ray from the point and adds one for each path crossing it counter-clockwise and subtracts one for clockwise; the point is inside if the total is not zero. Even-odd just counts crossings: odd means inside. For a square with a smaller square inside it, both drawn the same direction, non-zero says the inner square is filled too (winding 2) while even-odd leaves a hole (two crossings). If the inner square is drawn in the opposite direction, non-zero also gives a hole (1 − 1 = 0), which is how you cut a hole without switching rules: reversing() the inner path. Set it with usesEvenOddFillRule on a UIBezierPath, or fillRule = .evenOdd on a CAShapeLayer, or by passing the rule to CGPath.contains(_:using:).

Two practical points. contains(_:) tests the filled area under the active rule and ignores stroke width, so a straight line (zero area) never contains a point, even one right on it; to hit-test a thick line, build the stroked outline with copy(strokingWithWidth:lineCap:lineJoin:miterLimit:) and test that, or test distance yourself. And a path’s bounds excludes the stroke too: the stroked outline of a 100-point line of width 10 is 10 points tall, though the path’s bounds are 0 tall. Use these for custom hit regions, ring or donut shapes, and masks with holes.

Non-zero fills same-direction nesting; even-odd and a reversed inner path make a hole; a line has no area.

Common traps

  • Hit-testing a stroked line with contains. The path has no area; test the stroked outline.
  • Making a hole with two same-direction rectangles under non-zero. The hole stays filled; reverse the inner path or use even-odd.

They might follow up with

  • How do you draw a donut? Two circles with even-odd, or an outer path and a reversed inner one.
  • Does bounds include the line width? No; the stroked outline does.

Related questions

  • 9.5.1 How do you draw with Core Graphics and UIBezierPath, and how do scale and caching affect it?
  • 7.1.1 How does UIKit decide which view receives a touch, and why can a subview outside its parent’s bounds not be…
  • 9.11.1 In which order do CGAffineTransform operations apply, and what do scaledBy, rotated and translatedBy do…

← In which order do CGAffineTransform operations apply, and what do…   How do you measure text correctly: font metrics, boundingRect options… →