Winding number vs ray casting for concave zones
Ray casting is the containment test everyone writes first, and for a simple polygon it is correct, fast and adequate. For the shapes a real operational zone set contains — self-touching boundaries, holes, a zone digitised twice with reversed winding, two administrative areas sharing an edge — it starts producing answers that depend on details nobody intended to specify. The winding number gives a different answer in exactly those cases, and knowing which one you want is the decision this page is about. It belongs to on-device geometry filtering inside Local Spatial Processing Patterns.
The two rules
Ray casting counts how many times a ray from the query point crosses the boundary. Odd means inside. This is the even-odd rule, and it treats a region enclosed twice — a polygon that loops back over itself — as outside, because the ray crosses it twice.
Winding number sums the signed turns the boundary makes around the query point. Non-zero means inside. A region enclosed twice in the same direction has a winding number of two and counts as inside; one enclosed clockwise and again anticlockwise has a winding number of zero and counts as outside.
For a simple, non-self-intersecting ring the two agree everywhere. The disagreement appears exactly where the geometry is not simple, which on a zone set imported from several sources is more often than anyone expects.
Both, in the same pass
The two rules share almost all of their work: both walk the edges, both test whether the edge straddles the query’s horizontal line, both compute the same side-of-line determinant. Computing them together costs one extra accumulator, which makes disagreement detectable rather than invisible.
/* contain.c — even-odd and winding-number containment in one pass.
* Build: cc -O2 -fno-exceptions -c contain.c
* Integer coordinates (projected centimetres) so the side test is exact:
* a floating-point cross product near zero is the source of most
* boundary-case disagreements between two implementations. */
#include <stdint.h>
typedef struct { int32_t x, y; } pt_t;
typedef struct {
int inside_evenodd; /* ray-casting verdict */
int winding; /* signed winding number */
int on_boundary; /* the query lies exactly on an edge */
} contain_t;
/* Sign of the cross product (b-a) x (p-a): >0 left, <0 right, 0 collinear. */
static int side_of(pt_t a, pt_t b, pt_t p)
{
int64_t cross = (int64_t)(b.x - a.x) * (p.y - a.y)
- (int64_t)(b.y - a.y) * (p.x - a.x);
return (cross > 0) - (cross < 0);
}
contain_t contain_point(const pt_t *ring, uint16_t n, pt_t p)
{
contain_t out = {0, 0, 0};
for (uint16_t i = 0, j = (uint16_t)(n - 1); i < n; j = i++) {
pt_t a = ring[j], b = ring[i];
/* Half-open crossing rule: count an edge only when one endpoint is
* strictly above the ray and the other is not. This settles the
* vertex-on-ray and horizontal-edge cases without a special case. */
int a_above = a.y > p.y;
int b_above = b.y > p.y;
if (a_above != b_above) {
int s = side_of(a, b, p);
if (s == 0) { out.on_boundary = 1; } /* exactly on this edge */
/* Even-odd: crossing to the right of p flips the parity. */
if ((b_above && s > 0) || (!b_above && s < 0)) {
out.inside_evenodd ^= 1;
/* Winding: upward crossing to the left adds, downward
* crossing to the right subtracts. */
out.winding += b_above ? 1 : -1;
}
}
}
return out;
}
/* The caller decides which rule applies, and is told when they disagree. */
int contains(const pt_t *ring, uint16_t n, pt_t p, int use_winding,
uint32_t *disagreements)
{
contain_t c = contain_point(ring, n, p);
int by_wind = c.winding != 0;
if (by_wind != c.inside_evenodd) {
(*disagreements)++;
}
return use_winding ? by_wind : c.inside_evenodd;
}
The disagreements counter is the reason to compute both. Exported as a metric, it says how often the zone set contains geometry where the rule choice matters — and on a well-formed layer it should be exactly zero forever. The first time it is not, a zone was imported with a winding or a self-intersection nobody checked, and it is far better to learn that from a counter than from a report of a missed crossing.
Which rule for which data
Three data conventions cover most zone sets, and each implies a rule.
Simple rings, no holes — a fenced yard, a delivery zone, a site boundary. Both rules agree; use ray casting, and keep the disagreement counter as an import-validity check.
Rings with holes, wound oppositely — the GeoJSON and Shapefile convention, where an outer ring is anticlockwise and its holes are clockwise. Winding number is correct and even-odd also works, because opposite winding produces an even crossing count inside a hole. Either rule is safe provided the winding convention was honoured on import, which is the part that fails.
Rings with holes, all wound the same — common in data exported by tools that discard winding. Even-odd is correct here and winding is wrong: it reports the hole as inside. If the layer cannot be re-wound, use ray casting and record why.
The general rule: use even-odd unless the data has a winding convention you control and verify. Winding number’s extra expressiveness is only an advantage if the winding actually carries information, and in imported operational data it frequently does not.
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| CPU | Both rules are linear in vertex count | One pass computes both; the prefilter upstream is what bounds how often this runs |
| RAM | No allocation is acceptable in the hot path | Stack-only, integer arithmetic, no dynamic structures |
| Determinism | Two devices must agree exactly | Integer coordinates and an exact cross product; no floating-point comparison near zero |
| Correctness | Boundary points are a contract decision | on_boundary is returned rather than folded into the verdict |
| Observability | A rule disagreement is invisible by default | Disagreement counter exported as a metric |
Gotchas and edge cases
- The boundary is a third state, and the caller must define it. A point exactly on an edge is neither in nor out until someone decides. Define it once — “on the boundary counts as inside” is the usual choice — and apply it identically everywhere, including in the reconciliation running upstream.
- Integer coordinates are what make this reproducible. In floating point,
side_ofnear zero depends on rounding, so a point on an edge can be inside on one device and outside on another. The quantisation described in integer microdegree quantization is what makes the comparison exact. - Multi-part zones need the rule applied across all parts. A zone that is two disjoint areas is one logical zone; run the accumulation over every ring before deciding, rather than testing each ring independently and OR-ing the results, which gives the wrong answer for a hole in the second part.
- The half-open crossing rule is not optional. Counting an edge when both endpoints are on the ray, or when one is exactly on it, double-counts and flips the parity. The
a_above != b_abovetest with a strict comparison is what handles vertices and horizontal edges without special cases. - Validate winding at import, not at query time. Computing ring orientation is one pass over the vertices and it belongs in the layer import, where the answer can be recorded. Doing it per query is a linear cost paid over and over for an answer that never changes.
Integration
Call the combined function from behind the envelope prefilter described in the parent guide, so it only ever runs on candidates. Pass use_winding from the layer metadata rather than hard-coding it — a fleet operating over several imported layers may legitimately need different rules for different ones, and the metadata is where that decision was already recorded.
Export the disagreement counter alongside the containment latency histogram. Together they say something no single metric does: how expensive the exact stage is, and whether the data it is running on is the shape everyone assumed.
Reconciling with the upstream engine
The device is rarely the only thing evaluating containment. A platform-side reconciler, a reporting query and an analyst’s desktop GIS all run their own tests, and they do not all use the same rule. PostGIS and most desktop tools apply the even-odd rule with an explicit handling of ring orientation; some rendering pipelines apply non-zero winding by default; and a hand-written service may do either.
The consequence is a class of dispute that is very hard to diagnose: the device says a vehicle was inside, the platform says it was not, both are running correct code, and the polygon has a self-intersection nobody has looked at since it was digitised in 2019.
Two habits prevent it. Record the rule with the verdict — one field, three possible values — so a disagreement is immediately attributable rather than mysterious. And run the reconciler’s rule against the device’s zone layer at import time, comparing verdicts over a grid of sample points across each zone’s envelope. Any point where the two disagree identifies a polygon that needs repairing, and finding those on a build host is a morning’s work against a lifetime of intermittent disputes.
Related
- On-Device Geometry Filtering — the funnel this exact test sits at the end of.
- Implementing polygon containment checks in C — the ray-casting implementation and its degenerate cases.
- Integer microdegree quantization for coordinate storage — the representation that makes the side test exact.