Winding number and point-in-polygon for arbitrary (non-convex, self-intersecting) polygons, pure Python with zero dependencies.
Project description
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file winding_number-0.2.0.tar.gz.
File metadata
- Download URL: winding_number-0.2.0.tar.gz
- Upload date:
- Size: 891.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
376d1de0c57733e302ffc82527819e0ed5d97007e8efc6b764464992085f6fd0
|
|
| MD5 |
b44671f85f33f467ad9029f16a7bb100
|
|
| BLAKE2b-256 |
2facad655431451ad2d2b3c76e71abd53e2b1b8bf02e2d435413ab78948b89b2
|
File details
Details for the file winding_number-0.2.0-py3-none-any.whl.
File metadata
- Download URL: winding_number-0.2.0-py3-none-any.whl
- Upload date:
- Size: 11.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0a9ebe84a25a61f418eb367652b02a3119bf8f50cb7a9b490e9ee8abb5e50064
|
|
| MD5 |
8b79ac1f29a800cbf62191bf00a0bb03
|
|
| BLAKE2b-256 |
82b8ae3c62e4e027589c5d3582d975747e204572bf8f491d260680448e637bd1
|