Functional Weave
Code in Python

geo.point-in-polygon

Whether a latitude/longitude point lies inside a polygon (ray casting; the boundary counts as inside).

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 20 tests, run in TypeScript, Python and Rust.

What it does

Whether a point lies inside a polygon: geofences, delivery zones, "is this address in the congestion charge area".

## Rules

For example

  • point_in_polygon(lat 5, lng 5, polygon ×4) → true a point in the middle of a square
  • point_in_polygon(lat 5, lng 15, polygon ×4) → false a point east of the square
  • point_in_polygon(lat 0, lng 5, polygon ×4) → true a point on the bottom edge counts as inside

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def point_in_polygon(point: GeoPoint, polygon: Sequence[GeoPoint]) -> bool
pointGeoPointthe location to test
polygonGeoPoint[]the ring's vertices in order; closing the ring (last = first) is optional
returnsbool

The type it declares, generated into your project

@dataclass(frozen=True)
class GeoPoint:
    """A position in degrees."""

    #: latitude, -90 to 90
    lat: float
    #: longitude, -180 to 180
    lng: float

Your code names it in one line, in the file that uses it

from fune.geo.point_in_polygon import point_in_polygon  # geo.point-in-polygon@^1
impl/python.py · 87 lines · open · raw
import math
from typing import List, Sequence

from .geo_point_in_polygon_types import GeoPoint


def point_in_polygon(point: GeoPoint, polygon: Sequence[GeoPoint]) -> bool:
    """Whether ``point`` lies inside ``polygon``, by ray casting on the lng/lat plane.

    A point exactly on an edge or a vertex counts as inside: a delivery address
    on the boundary line of a zone is in the zone. That is checked explicitly
    first, because plain ray casting answers boundary points inconsistently.

    Only + - * / and comparisons are used, in the same order in every
    language, so the answer is identical in Python, TypeScript and Rust even
    where floating point rounding decides it.
    """
    _check_point(point)
    if isinstance(polygon, (str, bytes)) or not isinstance(polygon, (list, tuple)):
        raise TypeError("polygon must be a list of points")
    for vertex in polygon:
        _check_point(vertex)

    # Work in floats throughout, as JavaScript and Rust do, so an integer
    # coordinate takes exactly the same arithmetic path.
    point = GeoPoint(lat=float(point.lat), lng=float(point.lng))
    ring: List[GeoPoint] = [GeoPoint(lat=float(v.lat), lng=float(v.lng)) for v in polygon]
    # A closed ring repeats its first vertex at the end; that is not an edge.
    if len(ring) >= 2 and _same(ring[0], ring[-1]):
        ring = ring[:-1]
    if _count_distinct(ring) < 3:
        raise ValueError("a polygon needs at least 3 distinct vertices")

    n = len(ring)
    for i in range(n):
        if _on_segment(point, ring[i], ring[(i + 1) % n]):
            return True

    inside = False
    for i in range(n):
        a = ring[i]
        b = ring[(i + 1) % n]
        # Half-open test so a ray passing exactly through a vertex counts it once.
        if (a.lat > point.lat) != (b.lat > point.lat):
            cross_lng = a.lng + ((point.lat - a.lat) * (b.lng - a.lng)) / (b.lat - a.lat)
            if point.lng < cross_lng:
                inside = not inside
    return inside


def _on_segment(p: GeoPoint, a: GeoPoint, b: GeoPoint) -> bool:
    cross = (b.lng - a.lng) * (p.lat - a.lat) - (b.lat - a.lat) * (p.lng - a.lng)
    if cross != 0:
        return False
    return (
        min(a.lng, b.lng) <= p.lng <= max(a.lng, b.lng)
        and min(a.lat, b.lat) <= p.lat <= max(a.lat, b.lat)
    )


def _same(a: GeoPoint, b: GeoPoint) -> bool:
    return a.lat == b.lat and a.lng == b.lng


def _count_distinct(ring: Sequence[GeoPoint]) -> int:
    count = 0
    for i in range(len(ring)):
        if not any(_same(ring[i], ring[j]) for j in range(i)):
            count += 1
    return count


def _is_number(value: object) -> bool:
    return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)


def _check_point(p: object) -> None:
    if not hasattr(p, "lat") or not hasattr(p, "lng"):
        raise TypeError("a point must have lat and lng")
    if not _is_number(p.lat):  # type: ignore[attr-defined]
        raise TypeError("latitude must be a finite number of degrees")
    if not _is_number(p.lng):  # type: ignore[attr-defined]
        raise TypeError("longitude must be a finite number of degrees")
    if p.lat < -90 or p.lat > 90:  # type: ignore[attr-defined]
        raise ValueError("latitude must be between -90 and 90 degrees")
    if p.lng < -180 or p.lng > 180:  # type: ignore[attr-defined]
        raise ValueError("longitude must be between -180 and 180 degrees")

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add geo.point-in-polygon
Download for Python geo.point-in-polygon-1.0.0-python.fune · 12,614 bytes sha256 972670f2d9788a7244c952c3d83aafd9ade2d19a1176eab63a825c113e8b5004

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./geo.point-in-polygon-1.0.0-python.fune, or fetch it from a terminal with fune pull geo.point-in-polygon@1.0.0:python.

The whole function, every language, is one file too: geo.point-in-polygon-1.0.0.fune, 19,392 bytes, sha256 200241894c1b058f6c2763c8b07b0d3e658dbbd10c6a84dfbb9cf4027c03a4f8. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before geo.point-in-polygon

after — your function gets the result and the arguments, and returns the final result.

# fune: after geo.point-in-polygon

replace — it requires no other capability, so there is no dependency to replace.

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show geo.point-in-polygon --steps.

# fune: step geo.point-in-polygon after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a point in the middle of a square lat 5, lng 5, polygon ×4 → true
a point east of the square lat 5, lng 15, polygon ×4 → false
a point on the bottom edge counts as inside lat 0, lng 5, polygon ×4 → true
a point on the east edge counts as inside (plain ray casting says outside here) lat 5, lng 10, polygon ×4 → true
a vertex counts as inside lat 10, lng 10, polygon ×4 → true
on the line extending an edge but beyond the corner is outside lat 0, lng 15, polygon ×4 → false
a closed ring (last vertex repeats the first) gives the same answer lat 5, lng 5, polygon ×5 → true
clockwise and anticlockwise rings agree lat 5, lng 5, polygon ×4 → true
concave U shape: a point in the notch is outside lat 5, lng 5, polygon ×8 → false
concave U shape: a point in the left arm is inside lat 5, lng 1, polygon ×8 → true
Show the other 10 tests
CaseArgumentsExpected
concave U shape: a point in the base under the notch is inside lat 2, lng 5, polygon ×8 → true
concave U shape: the inner corner of the notch is on the boundary lat 3, lng 5, polygon ×8 → true
a ray through a vertex of a diamond counts the crossing once: inside lat 5, lng 2, polygon ×4 → true
a ray through a vertex of a diamond counts the crossing once: outside lat 5, lng -1, polygon ×4 → false
a small triangle over London, point inside lat 51.52, lng -0.1, polygon ×3 → true
a small triangle over London, point outside near the apex lat 51.59, lng -0.15, polygon ×3 → false
two vertices are not a polygon lat 0, lng 0, polygon ×2 → error: a polygon needs at least 3 distinct vertices
repeated vertices do not count twice lat 0, lng 0, polygon ×4 → error: a polygon needs at least 3 distinct vertices
a vertex latitude out of range is an error lat 0, lng 0, polygon ×3 → error: latitude must be between -90 and 90 degrees
a point longitude out of range is an error lat 0, lng 200, polygon ×4 → error: longitude must be between -180 and 180 degrees

More from the author

- **The boundary is inside.** A point exactly on an edge or a vertex returns true. Plain ray casting answers boundary points inconsistently (a point on the east edge of a square comes out outside, one on the west edge inside), so every edge is checked first: zero cross product and within the edge's extent. - **Even-odd rule.** A ray is cast towards increasing longitude and crossings are counted, with a half-open test on latitude so a ray passing exactly through a vertex counts it once, not twice. For a self-intersecting polygon the overlapping parts count as outside. - **Rings.** Vertices in order, either winding. Closing the ring (repeating the first vertex at the end) is optional. Fewer than 3 distinct vertices is an error. - **Coordinates** must be in -90..90 and -180..180.

## What it deliberately does not do

It works on the flat longitude/latitude plane, treating edges as straight lines in degrees. That is how GeoJSON polygons and most map tools draw them, and it is accurate for zones of city or county size. It does not handle holes (test the outer ring, then each hole), polygons that cross the antimeridian (split them), or polygons that contain a pole.

## Floating point

Only + − × ÷ and comparisons are used, in the same order in TypeScript, Python and Rust, so all three give the same answer for every input, including points so close to an edge that rounding decides them. Boundary detection is exact when the coordinates' products are exact (integers, or few decimals at small scale); for a point within about 1e-15 degrees of an edge given in arbitrary decimals, rounding decides, identically in every language. On 4,000 random cases, half of them on an integer grid to hit edges and vertices, the three languages agreed on every answer.

Files

PathBytes
README.md1,942
impl/python.py3,462
impl/rust.rs3,362
impl/typescript.ts3,158
vectors.json4,648