Describe the bug
geoTrans.isCounterClockWise() decides a polygon's winding direction with a majority vote on vertex angles measured relative to whichever vertex is listed first (V0). This is not rotation-invariant: the identical polygon can get a different (wrong) answer purely depending on which vertex is listed first — or, with vertex order held completely fixed, purely from the polygon's physical orientation in the plane (arctan2's ±180° branch cut moves relative to the vertex angles as the shape rotates). This is not limited to concave shapes — a plain convex square is affected too.
polygon2Raster() uses this sign to decide whether its radius tolerance dilates or shrinks a polygon before rasterizing it. The real release-raster call in com1DFA.py (radius = sqrt(2), ~line 1249) is meant to dilate slightly; when winding is misjudged, it shrinks instead. On a real 59-vertex building-footprint release polygon this reduced 6 legitimate non-empty release cells to 1 — roughly 92% of the intended release mass silently dropped, no error or warning.
To Reproduce
Take l_shape = [[0,0],[0,4],[3,4],[3,2],[1,2],[1,0]].
Rotate it 100° about vertex 0 — vertex order/starting index untouched:
[0.000000, 0.000000], [-3.939231, -0.694593], [-4.460176, 2.259831],
[-2.490560, 2.607127], [-2.143264, 0.637511], [-0.173648, 0.984808]
geoTrans.isCounterClockWise(matplotlib.path.Path(vertices)) on this exact order returns True.
The correct answer (shoelace formula, sum(x_i·y_(i+1) − x_(i+1)·y_i) > 0) is False.
Sweeping this same shape through a full 360° turn (vertex order fixed throughout) finds a contiguous 90° band (91°–180°) where this happens — a quarter of all possible orientations for this one shape.
Expected behavior
isCounterClockWise() should return the same, correct answer regardless of which vertex is listed first or the polygon's absolute orientation — winding is a geometric property of the polygon, not of vertex-list bookkeeping.
Screenshots
Interactive derivation, steps both algorithms side-by-side over six test polygons (including this exact case) can be found on https://avaframe-bug.skerlak.ch.
OS:
N/A — pure Python/numpy/matplotlib logic bug, not platform-specific
Full derivation, six test shapes (a convex square is also affected; a more-concave star and a circle happen to survive every rotation tested), and the fix are in bug_report.md (attached in the linked package).
Proposed fix, already implemented and tested against the existing suite:
def isCounterClockWise(path):
x = path.vertices[:, 0]
y = path.vertices[:, 1]
signedArea = np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y)
return signedArea > 0
Runnable pytest package attached (shapes.py, ccw_compare.py, test_isCounterClockWise_bug.py): fails against the current implementation, passes against the proposed one.
Additional context
bojan@skerlak.ch
www.skerlak.ch
Describe the bug
geoTrans.isCounterClockWise() decides a polygon's winding direction with a majority vote on vertex angles measured relative to whichever vertex is listed first (V0). This is not rotation-invariant: the identical polygon can get a different (wrong) answer purely depending on which vertex is listed first — or, with vertex order held completely fixed, purely from the polygon's physical orientation in the plane (arctan2's ±180° branch cut moves relative to the vertex angles as the shape rotates). This is not limited to concave shapes — a plain convex square is affected too.
polygon2Raster() uses this sign to decide whether its radius tolerance dilates or shrinks a polygon before rasterizing it. The real release-raster call in com1DFA.py (radius = sqrt(2), ~line 1249) is meant to dilate slightly; when winding is misjudged, it shrinks instead. On a real 59-vertex building-footprint release polygon this reduced 6 legitimate non-empty release cells to 1 — roughly 92% of the intended release mass silently dropped, no error or warning.
To Reproduce
Take l_shape = [[0,0],[0,4],[3,4],[3,2],[1,2],[1,0]].
Rotate it 100° about vertex 0 — vertex order/starting index untouched:
[0.000000, 0.000000], [-3.939231, -0.694593], [-4.460176, 2.259831],
[-2.490560, 2.607127], [-2.143264, 0.637511], [-0.173648, 0.984808]
geoTrans.isCounterClockWise(matplotlib.path.Path(vertices)) on this exact order returns True.
The correct answer (shoelace formula, sum(x_i·y_(i+1) − x_(i+1)·y_i) > 0) is False.
Sweeping this same shape through a full 360° turn (vertex order fixed throughout) finds a contiguous 90° band (91°–180°) where this happens — a quarter of all possible orientations for this one shape.
Expected behavior
isCounterClockWise() should return the same, correct answer regardless of which vertex is listed first or the polygon's absolute orientation — winding is a geometric property of the polygon, not of vertex-list bookkeeping.
Screenshots
Interactive derivation, steps both algorithms side-by-side over six test polygons (including this exact case) can be found on https://avaframe-bug.skerlak.ch.
OS:
N/A — pure Python/numpy/matplotlib logic bug, not platform-specific
Full derivation, six test shapes (a convex square is also affected; a more-concave star and a circle happen to survive every rotation tested), and the fix are in bug_report.md (attached in the linked package).
Proposed fix, already implemented and tested against the existing suite:
def isCounterClockWise(path):
x = path.vertices[:, 0]
y = path.vertices[:, 1]
signedArea = np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y)
return signedArea > 0
Runnable pytest package attached (shapes.py, ccw_compare.py, test_isCounterClockWise_bug.py): fails against the current implementation, passes against the proposed one.
Additional context
bojan@skerlak.ch
www.skerlak.ch