Summary
union2d and union2d_curved (and all other clip-based operations) silently drop clockwise-wound polygons with no error or warning. A CW polygon passed to union2d simply disappears from the output.
This happens because clip defaults to PolyFillTypePositive, which treats CW contours (winding number = -1) as holes. When a standalone CW polygon is passed (not contained within an outer CCW contour), it is effectively a "hole in nothing" and gets discarded.
While the docstring mentions "Clockwise polygons are holes", this behavior is a significant footgun because:
- Users can easily construct CW polygons (e.g., via
Polygon(reverse(points(p))), coordinate transformations, or importing from external sources)
- The operation completes silently -- no error, no warning
- The polygon simply vanishes from the result
Minimal Reproducible Example
using DeviceLayout
using DeviceLayout.Polygons
using DeviceLayout.Curvilinear
# Two non-overlapping rectangles
r1 = Polygon(Point.([(0,0), (10,0), (10,5), (0,5)])) # CCW
r2 = Polygon(Point.([(20,0), (30,0), (30,5), (20,5)])) # CCW
r2_cw = Polygon(reverse(points(r2))) # CW (reversed)
# Verify winding
shoelace(p) = let pts = points(p)
sum(getx(pts[i])*gety(pts[mod1(i+1,length(pts))]) -
getx(pts[mod1(i+1,length(pts))])*gety(pts[i]) for i in 1:length(pts)) / 2
end
println("r1 signed area (CCW>0): ", shoelace(r1)) # 50.0
println("r2_cw signed area (CW<0): ", shoelace(r2_cw)) # -50.0
# BUG: CW polygon silently dropped
println(length(to_polygons(union2d([r1, r2_cw])))) # 1 (expected 2!)
println(length(union2d_curved([r1, r2_cw]))) # 1 (expected 2!)
# Control: both CCW works fine
println(length(to_polygons(union2d([r1, r2])))) # 2 (correct)
Observed behavior
| Input |
union2d result |
Expected |
[ccw, cw] (non-overlapping) |
1 region |
2 regions |
[cw, cw] (non-overlapping) |
0 regions |
2 regions |
[cw] (single polygon) |
0 regions |
1 region |
[ccw, ccw] (non-overlapping) |
2 regions |
2 regions |
Root cause
In src/clipping.jl, all clip-based operations default to PolyFillTypePositive:
function clip(op::Clipper.ClipType, s::AbstractVector{Polygon{T}}, c::AbstractVector{Polygon{T}};
pfs::Clipper.PolyFillType=Clipper.PolyFillTypePositive,
pfc::Clipper.PolyFillType=Clipper.PolyFillTypePositive) where {T}
With PolyFillTypePositive, only regions with positive winding number (CCW) are considered "filled". CW contours have winding number -1 and are treated as holes. A standalone CW polygon (not inside any CCW contour) becomes a "hole in nothing" and is discarded.
Affected functions
All clip-based functions share this behavior:
union2d / union2d_curved
difference2d / difference2d_curved
intersect2d / intersect2d_curved
xor2d / xor2d_curved
Suggested fix
Consider one or more of:
- Emit a warning when a CW polygon is passed to
union2d (or other operations) that would be silently dropped
- Auto-normalize input polygon winding to CCW before clipping (users who intentionally want holes can use the
pfs/pfc kwargs)
- Use
PolyFillTypeNonZero or PolyFillTypeEvenOdd as the default, which treats both CW and CCW as filled regions (but changes hole semantics)
Option 1 (warning) is the least invasive. Option 2 is probably the best UX for union2d specifically, since "union of geometry" should not depend on winding order.
Summary
union2dandunion2d_curved(and all otherclip-based operations) silently drop clockwise-wound polygons with no error or warning. A CW polygon passed tounion2dsimply disappears from the output.This happens because
clipdefaults toPolyFillTypePositive, which treats CW contours (winding number = -1) as holes. When a standalone CW polygon is passed (not contained within an outer CCW contour), it is effectively a "hole in nothing" and gets discarded.While the docstring mentions "Clockwise polygons are holes", this behavior is a significant footgun because:
Polygon(reverse(points(p))), coordinate transformations, or importing from external sources)Minimal Reproducible Example
Observed behavior
union2dresult[ccw, cw](non-overlapping)[cw, cw](non-overlapping)[cw](single polygon)[ccw, ccw](non-overlapping)Root cause
In
src/clipping.jl, allclip-based operations default toPolyFillTypePositive:With
PolyFillTypePositive, only regions with positive winding number (CCW) are considered "filled". CW contours have winding number -1 and are treated as holes. A standalone CW polygon (not inside any CCW contour) becomes a "hole in nothing" and is discarded.Affected functions
All
clip-based functions share this behavior:union2d/union2d_curveddifference2d/difference2d_curvedintersect2d/intersect2d_curvedxor2d/xor2d_curvedSuggested fix
Consider one or more of:
union2d(or other operations) that would be silently droppedpfs/pfckwargs)PolyFillTypeNonZeroorPolyFillTypeEvenOddas the default, which treats both CW and CCW as filled regions (but changes hole semantics)Option 1 (warning) is the least invasive. Option 2 is probably the best UX for
union2dspecifically, since "union of geometry" should not depend on winding order.