Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/polygons.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions test/test_clipping.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions test/test_render.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading