Skip to main content

Winding number and point-in-polygon for arbitrary (non-convex, self-intersecting) polygons, pure Python with zero dependencies.

Project description

winding-number

winding-number logo

CI License: MIT

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_point pulls 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

winding_number-0.2.0.tar.gz (891.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

winding_number-0.2.0-py3-none-any.whl (11.3 kB view details)

Uploaded Python 3

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

Hashes for winding_number-0.2.0.tar.gz
Algorithm Hash digest
SHA256 376d1de0c57733e302ffc82527819e0ed5d97007e8efc6b764464992085f6fd0
MD5 b44671f85f33f467ad9029f16a7bb100
BLAKE2b-256 2facad655431451ad2d2b3c76e71abd53e2b1b8bf02e2d435413ab78948b89b2

See more details on using hashes here.

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

Hashes for winding_number-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0a9ebe84a25a61f418eb367652b02a3119bf8f50cb7a9b490e9ee8abb5e50064
MD5 8b79ac1f29a800cbf62191bf00a0bb03
BLAKE2b-256 82b8ae3c62e4e027589c5d3582d975747e204572bf8f491d260680448e637bd1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page