winding-number
Winding number and point-in-polygon for arbitrary (non-convex, self-intersecting) polygons. Pure Python, zero dependencies.
What is the winding number?
The winding number of a closed polygon around a point is the integer count of how many times the polygon winds around that point. A CCW polygon around an interior point has winding number +1; a CW polygon gives -1; an exterior point gives 0.
This is the basis of the nonzero rule: a point is inside iff winding_number != 0.
Two fill rules
This library implements both standard fill rules:
Nonzero rule (point_in_polygon): inside iff winding_number != 0. Correct for
non-convex and self-intersecting polygons; all regions with a non-zero winding count
are considered inside.
Even-odd rule (point_in_polygon_even_odd): inside iff crossing_number is odd.
Counts how many times a ray from the point crosses the polygon boundary; an odd count
means inside. The two rules agree for simple (non-self-intersecting) polygons and diverge
for self-intersecting ones.
Pentagram example
The canonical case where the rules disagree is a five-pointed star (pentagram) traced as the self-intersecting 5-vertex path (outer tips connected in skip-one order):
import math
from winding_number import winding_number, point_in_polygon, crossing_number, point_in_polygon_even_odd
n = 5
circle_pts = [
(math.cos(math.radians(90 - 72 * i)), math.sin(math.radians(90 - 72 * i)))
for i in range(n)
]
pentagram = [circle_pts[i % n] for i in [0, 2, 4, 1, 3]]
center = (0.0, 0.0)
winding_number(center, pentagram) # -2 (wound twice, CW)
point_in_polygon(center, pentagram) # True (nonzero rule: inside)
crossing_number(center, pentagram) # 2 (two crossings, even)
point_in_polygon_even_odd(center, pentagram) # False (even-odd rule: outside)
The central region is wound around twice, so the nonzero rule treats it as inside. The even-odd rule ignores winding direction and depth: two crossings is even, so outside.
Why zero-dependency vs. shapely or matplotlib?
The correct alternatives require heavy C extensions:
shapely.geometry.Point.within(polygon)requires shapely, which depends on GEOS (a C/C++ library).matplotlib.path.Path.contains_pointpulls in all of matplotlib (rendering, fonts, backends).
This library implements Sunday's winding-number algorithm in pure Python with zero runtime dependencies. No compilation, no GEOS, no plotting overhead.
Install
pip install winding-number
PyPI release pending. Install from source in the meantime:
git clone https://github.com/amaar-mc/winding-number cd winding-number pip install -e .
Quick start
from winding_number import winding_number, point_in_polygon, crossing_number, point_in_polygon_even_odd, signed_area, orientation, is_convex
# CCW unit square.
square = [(1, -1), (1, 1), (-1, 1), (-1, -1)]
winding_number((0, 0), square) # 1 (inside, CCW)
winding_number((2, 0), square) # 0 (outside)
point_in_polygon((0, 0), square) # True
point_in_polygon((2, 0), square) # False
signed_area(square) # 4.0 (positive = CCW)
orientation(square) # "CCW"
is_convex(square) # True
# Non-convex L-shape.
l_shape = [(0,0),(2,0),(2,1),(1,1),(1,2),(0,2)]
is_convex(l_shape) # False
point_in_polygon((1.0, 0.5), l_shape) # True (inside the bottom bar)
point_in_polygon((1.5, 1.5), l_shape) # False (the notch)
API
| Function | Description |
|---|---|
winding_number(point, polygon) |
Signed winding number (Sunday's algorithm with isLeft) |
point_in_polygon(point, polygon) |
True iff winding_number != 0 (nonzero rule) |
crossing_number(point, polygon) |
Number of rightward ray crossings (PNPOLY convention) |
point_in_polygon_even_odd(point, polygon) |
True iff crossing_number is odd (even-odd rule) |
signed_area(polygon) |
Shoelace formula; positive = CCW, negative = CW |
orientation(polygon) |
"CCW" or "CW"; raises ValueError on degenerate zero-area polygon |
is_convex(polygon) |
True iff all edge-pair cross products share the same sign |
A point is tuple[float, float] and a polygon is Sequence[tuple[float, float]].
All functions raise ValueError if the polygon has fewer than 3 vertices.
On-edge convention
Nonzero rule: a point exactly on a non-horizontal polygon edge is counted as inside
(winding_number returns +/-1). A point on a horizontal edge is counted as outside
(winding number 0).
Even-odd rule: the crossing test uses a strict < for the crossing x-coordinate,
so a point that lies exactly on a non-horizontal edge (where the ray x-coordinate equals
the crossing x) is counted as outside. Points on horizontal edges are outside (horizontal
edges are skipped). The two algorithms may therefore disagree at exact boundary points;
they agree at all strictly interior and strictly exterior points of simple polygons.
Both conventions are deterministic and require no epsilon. See docs/architecture.md for the full derivation.
License
MIT. Copyright (c) 2026 Amaar Chughtai.
Metadata
Release files for winding-number 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| winding_number-0.2.0.tar.gz | 891.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| winding_number-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 902.8 kB
Release files / winding_number-0.2.0.tar.gz
| Download URL | winding_number-0.2.0.tar.gz |
|---|---|
| Size | 891.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
376d1de0c57733e302ffc82527819e0ed5d97007e8efc6b764464992085f6fd0
|
|
BLAKE2b-256 checksum How to use checksums |
2facad655431451ad2d2b3c76e71abd53e2b1b8bf02e2d435413ab78948b89b2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|
Release files / winding_number-0.2.0-py3-none-any.whl
| Download URL | winding_number-0.2.0-py3-none-any.whl |
|---|---|
| Size | 11.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0a9ebe84a25a61f418eb367652b02a3119bf8f50cb7a9b490e9ee8abb5e50064
|
|
BLAKE2b-256 checksum How to use checksums |
82b8ae3c62e4e027589c5d3582d975747e204572bf8f491d260680448e637bd1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.13
|