Test iOS polygon hit detection: edges, holes and fill rules

Test iOS polygon hit detection: edges, holes and fill rules

Test iOS polygon hit detection: edges, holes and fill rules

A map selection appears to have a hole, yet clicking inside that hole still selects it. A point on a canvas lies exactly on a line segment, but the result changes with the zoom level. Problems like these usually have less to do with inaccurate remote desktop clicks than with rendering, coordinate conversion, and geometric classification following different contracts. Start by running a pure Swift regression script on a cloud Mac to separate geometry problems from UI problems.

Define three-way classification and coordinate units first

Do not start with a Boolean result. Classify each point as inside, outside, or on the boundary, then decide whether the boundary counts as selected. A map editor might allow edge selection, while an exclusion zone might need to keep its boundary off-limits too. These cases cannot share an undocumented Boolean rule.

This article covers simple closed rings made of straight line segments in a single planar coordinate system, with holes allowed inside the outer ring. It does not cover projecting latitude and longitude directly, repairing self-intersections, or measuring distances to curves. The query point and the vertices must go through the same coordinate transformation.

Geometric tolerance is not a touch target. The former stabilizes numerical classification; the latter is an interaction design choice. Do not quietly enlarge the selection area by increasing the tolerance.

If coordinates use logical canvas units, the tolerance must use those units too. A fixed on-screen click radius must be converted into canvas coordinates through the inverse transform. With nonuniform scaling, simply dividing by a single scale factor is not sufficient.

Identify boundaries with point-to-segment distance

Check the boundary before asking whether the path contains the point. Clamp the projection to the segment’s endpoints; otherwise, a point can be incorrectly classified as close to an infinitely extended line. When duplicate vertices create a zero-length edge, fall back to the distance to that point.

Save the following as polygon-check.swift. The example tolerance is 0.000001 coordinate units. It is intended only for these fixed test fixtures, not as a universal precision setting for maps or canvases.

import Foundation
import CoreGraphics

enum Location: Equatable {
    case inside, outside, boundary
}

func distance(_ p: CGPoint, _ a: CGPoint, _ b: CGPoint) -> CGFloat {
    let dx = b.x - a.x
    let dy = b.y - a.y
    let lengthSquared = dx * dx + dy * dy
    if lengthSquared == 0 {
        return hypot(p.x - a.x, p.y - a.y)
    }
    let projection = ((p.x - a.x) * dx + (p.y - a.y) * dy)
        / lengthSquared
    let t = min(1, max(0, projection))
    return hypot(p.x - a.x - t * dx, p.y - a.y - t * dy)
}

func locate(
    _ point: CGPoint,
    rings: [[CGPoint]],
    rule: CGPathFillRule,
    epsilon: CGFloat
) -> Location {
    precondition(epsilon.isFinite && epsilon >= 0)
    precondition(point.x.isFinite && point.y.isFinite)
    precondition(!rings.isEmpty)

    let path = CGMutablePath()
    for ring in rings {
        precondition(ring.count >= 3)
        precondition(ring.allSatisfy { $0.x.isFinite && $0.y.isFinite })
        for i in ring.indices {
            let next = (i + 1) % ring.count
            if distance(point, ring[i], ring[next]) <= epsilon {
                return .boundary
            }
        }
        path.move(to: ring[0])
        for vertex in ring.dropFirst() {
            path.addLine(to: vertex)
        }
        path.closeSubpath()
    }
    return path.contains(point, using: rule) ? .inside : .outside
}

These preconditions are appropriate for test data that has already passed input validation. Production code handling imported files should return validation errors that callers can handle, rather than letting invalid coordinates terminate the process. Also limit coordinate magnitudes to prevent overflow when squaring values: finite inputs do not guarantee finite intermediate results.

Use a fixed hole fixture to compare fill rules

Append the following code. The inner and outer rings run in the same direction, so the nonzero winding rule fills the inner ring, while the even-odd rule treats its interior as a hole. Both results are correct under their respective rules. A screenshot alone is not evidence of a geometry library bug.

let outer: [CGPoint] = [
    CGPoint(x: 0, y: 0), CGPoint(x: 10, y: 0),
    CGPoint(x: 10, y: 10), CGPoint(x: 0, y: 10)
]
let hole: [CGPoint] = [
    CGPoint(x: 3, y: 3), CGPoint(x: 7, y: 3),
    CGPoint(x: 7, y: 7), CGPoint(x: 3, y: 7)
]
let rings = [outer, hole]
let epsilon: CGFloat = 0.000001

func check(_ x: CGFloat, _ y: CGFloat, _ expected: Location) {
    precondition(
        locate(
            CGPoint(x: x, y: y),
            rings: rings,
            rule: .evenOdd,
            epsilon: epsilon
        ) == expected
    )
}

check(1, 1, .inside)
check(5, 5, .outside)
check(12, 5, .outside)
check(0, 5, .boundary)
check(3, 5, .boundary)
check(0, 0, .boundary)
check(-epsilon / 2, 5, .boundary)
check(-epsilon * 2, 5, .outside)

precondition(
    locate(
        CGPoint(x: 5, y: 5),
        rings: rings,
        rule: .winding,
        epsilon: epsilon
    ) == .inside
)

Run these commands in the node’s terminal:

xcrun --find swift
xcrun swift polygon-check.swift

Normal completion with a zero exit code means these assertions passed. Any failed assertion produces a nonzero exit code. Do not run this acceptance script with optimization settings that disable assertion checks.

Add orientation, transform, and degenerate cases

Rectangles exercise only the most basic path. At a minimum, extend the regression suite with concave polygons, horizontal edges, consecutive collinear vertices, and two nearby points on either side of a vertex. A point in a concave notch must remain outside; being inside the bounding rectangle must not make it selectable.

Under the even-odd rule, reversing the vertex order of any ring should leave the classification of every non-boundary sample unchanged. Translating all vertices and test points should also preserve the results. If you scale the entire fixture uniformly, scale the tolerance by the same factor; otherwise, you are no longer comparing the same boundary band.

Rings with fewer than three distinct vertices, entirely collinear rings, self-intersections, and holes extending beyond the outer ring must either be rejected during input validation or have explicitly defined behavior. The vertex-count check in this example is not a polygon-validity check. Do not automatically remove problematic points and continue testing: that could turn the user’s input into a different region.

Capture reproducible failures

For each failure, record the ordered ring vertices, point coordinates, fill rule, tolerance, and transformation matrix. Preserve floating-point values with enough precision rather than keeping only the rounded coordinates shown in the UI. Rounding can collapse points on opposite sides of a boundary to the same value.

When reconnecting the iOS UI, focus on the coordinate chain

Once the pure geometry fixtures pass, reconnect the same classification function to the app. Check each stage: whether touch points are converted from view coordinates to path coordinates, whether the scroll offset is subtracted twice, and whether path rendering and hit testing use the same fill rule.

Selecting the same logical point before and after zooming should produce the same result. If it does not, inspect the inverse transform before increasing the tolerance. Stroke width is not the same as the filled area, either. If the stroke needs to respond to clicks, give it separate hit-testing logic.

Keep two kinds of tests in the end: a pure Swift script for the geometry contract, and a small set of UI tests for coordinate mapping. The former can run on a remote Mac with each commit; the latter verifies the actual interaction chain. When a test fails, this separation lets you first identify whether the problem lies in the data, the rules, or the view transformations, instead of repeatedly guessing where the click landed.

Frequently asked questions

Why not use CGPath.contains as the final hit-test result?

Whether an edge counts as selected is a separate product decision. Check the distance to each segment first, classify the point as boundary, inside or outside, then apply the interaction policy.

Must a hole use reversed vertex order?

With even-odd filling, contour direction does not determine the hole. With nonzero winding, direction affects the result. Rendering and hit testing must use the same fill rule.

Test your remote Mac with your own tasks.

Rent an exclusively assigned Mac mini M4 and use it through the macOS GUI or command line. Ideal for testing connectivity and workflows before committing. If you need long-running offline jobs or professional external hardware, compare the cost with buying your own Mac.

Rent a dedicated Mac mini

Rent for a day before you decide whether to buy.

Test the connection and your real-world tasks, then choose the rental period that works for you.