diff --git a/src/polygons.jl b/src/polygons.jl index a42bc8b1..e0c6df47 100644 --- a/src/polygons.jl +++ b/src/polygons.jl @@ -1540,7 +1540,20 @@ function _offset( add_path!(c, s0.p, j, e) end result = Clipper.execute(c, Float64(ustrip(delta))) #TODO: fix in clipper - return [Polygon(reinterpret(Point{T}, p)) for p in result] + polys = [Polygon(reinterpret(Point{T}, p)) for p in result] + # Fast path: single or zero contours cannot contain holes + if length(polys) <= 1 + return polys + end + # Check whether Clipper returned mixed orientations (holes among outer contours). + # CW contours are holes in Clipper's convention; CCW are outer boundaries. + orientations = [orientation(p) for p in polys] + if all(==(first(orientations)), orientations) + return polys # All same orientation — no holes + end + # Mixed orientations mean holes are present — recombine via union2d so that the + # PolyTree keeps hole topology, then flatten back to Polygons with interior cuts. + return to_polygons(union2d(polys)) end ### cutting algorithm diff --git a/test/test_clipping.jl b/test/test_clipping.jl index 4fa4f77a..97c46d8b 100644 --- a/test/test_clipping.jl +++ b/test/test_clipping.jl @@ -779,6 +779,35 @@ end poly = to_polygons(intersect2d(r0, pa => :test)) @test length(poly) == 12 end + + @testset "Offset preserves holes (#11)" begin + # Create a ring shape: outer square with inner hole + outer = Rectangle(100nm, 100nm) + inner = Rectangle(60nm, 60nm) + Point(20nm, 20nm) + ring = difference2d(outer, inner) + + # Offset outward — should produce a single polygon (with interior cut + # encoding the hole), not two separate flat polygons. + # Without the fix, Clipper returns 2 separate Polygons (one for the + # expanded outer contour, one for the shrunk inner hole contour) because + # _offset loses hole topology. With the fix, mixed-orientation contours + # are recombined via union2d and flattened with interior cuts. + result = offset(ring, 5nm) + @test length(result) == 1 # single ring polygon with interior cut + + # The result should be larger than the original outer rectangle + # (offset expands outward by 5nm on each side) + b = bounds(result[1]) + @test width(b) > 100nm + @test height(b) > 100nm + + # Also test with unitless integers (same bug path) + outer_i = Rectangle(100, 100) + inner_i = Rectangle(60, 60) + Point(20, 20) + ring_i = difference2d(outer_i, inner_i) + result_i = offset(ring_i, 5) + @test length(result_i) == 1 + end end @testitem "Clipping CurvilinearPolygon" setup = [CommonTestSetup] begin diff --git a/test/test_render.jl b/test/test_render.jl index ba6f6fba..ffe180a3 100644 --- a/test/test_render.jl +++ b/test/test_render.jl @@ -1001,8 +1001,10 @@ end atol=1e-9μm ) # Halo uses original ClippedPolygon, hole in the center - # Offset returns holes as reversed-orientation polygons [issue #11] - @test Polygons.orientation(halo(dr1, 0.1μm)[2]) == -1 # still has a hole + # Offset preserves holes as interior cuts in keyhole polygons [issue #11 fix] + h = halo(dr1, 0.1μm) + @test length(h) == 1 # single keyhole polygon (hole encoded as interior cut) + @test length(h[1].p) > 4 # more vertices than a simple rectangle = hole present @test footprint(union2d(r1, r1 + Point(40, 0)μm)) isa Rectangle # multipolygon => use bounds @test halo(union2d(r3), 1μm, -0.5μm) == dr1 # ClippedPolygon halo with inner delta end