diff --git a/CHANGELOG.md b/CHANGELOG.md index 114a5b79f..d19f2d872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format of this changelog is based on ## Unreleased +### Fixed + + - Zero-length path nodes and compound subsegments with continuous styles (for example a + zero-angle `Turn`) are ignored during rendering instead of triggering the closed-segment + check meant for full turns. As a result, zero-length pieces (such as those left around + overlay terminations) no longer emit degenerate zero-area polygons in rendered output. (#269) + - Loops removed by styling (`NoRender`, including via `OptionalStyle`/`optional_entity` or a + `StyleDict` entry, nested inside `Rounded`) expand to no polygons instead of zero-point + `Polygon`s, which reached `Cell`s and could not be written to GDS. (#269) + ## 1.16.0 (2026-07-20) In addition to new features and bug fixes, this release substantially refactors diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 507c3533e..be298f728 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -159,6 +159,9 @@ function to_polygons( rtol=nothing, kwargs... ) where {T} + # An empty CurvilinearPolygon is the sentinel for a NoRender styled_loop() + # but empty polygons are invalid for GDS export and operations like `bounds` + isempty(e.p) && return Polygon{T}[] i = 1 p = Point{T}[] @@ -282,12 +285,13 @@ CurvilinearRegion(points::Vector{Point{T}}, curves, curve_start_idx) where {T} = # Using `union2d` rather than `difference2d` keeps hole winding consistent with `ClippedPolygon` # and is robust to styles (e.g. composed `Rounded`) that round each loop independently and # preserve winding. See #241. -function to_polygons(e::CurvilinearRegion; kwargs...) - isempty(e.holes) && return [to_polygons(e.exterior; kwargs...)] +function to_polygons(e::CurvilinearRegion{T}; kwargs...) where {T} + # Empty loops are `NoRender` sentinels; drop them here + isempty(points(e.exterior)) && return Polygon{T}[] + holes = filter(h -> !isempty(points(h)), e.holes) + isempty(holes) && return [to_polygons(e.exterior; kwargs...)] return to_polygons( - union2d( - vcat(to_polygons(e.exterior; kwargs...), _hole_polygon.(e.holes; kwargs...)) - ) + union2d(vcat(to_polygons(e.exterior; kwargs...), _hole_polygon.(holes; kwargs...))) ) end @@ -300,13 +304,16 @@ end function _hole_polygon(h::CurvilinearPolygon; kwargs...) return Polygon(reverse(points(to_polygons(_reverse(h); kwargs...)))) end -function to_polygons(e::CurvilinearRegion, sty::Polygons.Rounded; kwargs...) - isempty(e.holes) && return [to_polygons(e.exterior, sty; kwargs...)] +function to_polygons(e::CurvilinearRegion{T}, sty::Polygons.Rounded; kwargs...) where {T} + # See the unstyled method above: empty loops are `NoRender` sentinels and drop out. + isempty(points(e.exterior)) && return Polygon{T}[] + holes = filter(h -> !isempty(points(h)), e.holes) + isempty(holes) && return [to_polygons(e.exterior, sty; kwargs...)] return to_polygons( union2d( vcat( to_polygons(e.exterior, sty; kwargs...), - [to_polygons(h, sty; kwargs...) for h in e.holes] + [to_polygons(h, sty; kwargs...) for h in holes] ) ) ) @@ -903,7 +910,12 @@ pathtopolys(seg::Paths.Straight{T}, sty::Paths.TaperCPW; kwargs...) where {T} = to_polygons(seg, sty; kwargs...) # Dispatch node->primitive based on kernel and requirements for representing node exactly -function pathtopolys(node::Paths.Node; kwargs...) +function pathtopolys(node::Paths.Node{T}; kwargs...) where {T} + # Zero-length continuous-style nodes expand to nothing. + # Their coincident endpoints would otherwise trip the closed-segment check meant for full turns + iszero(pathlength(node.seg)) && + node.sty isa Paths.ContinuousStyle && + return CurvilinearPolygon{T}[] return pathtopolys(node, islinear(node.seg, node.sty); kwargs...) end # A linear path can be exactly represented using plain Polygons. diff --git a/src/render/compound.jl b/src/render/compound.jl index cb2efdd94..4b7700bf3 100644 --- a/src/render/compound.jl +++ b/src/render/compound.jl @@ -4,6 +4,9 @@ function _compound_pin_render(f::Paths.CompoundSegment{T}, s::Paths.Style, leaf) stops = starts .+ pathlength.(f.segments) pieces = map(f.segments, starts, stops) do se, l0, l + # Zero-length subsegments (e.g. zero-angle turns) carry no geometry; skip them + # so their coincident endpoints don't trip the closed-segment check (issue #269). + iszero(pathlength(se)) && return Polygon{T}[] return vcat(leaf(se, Paths.pin(s; start=l0, stop=l))) end diff --git a/test/test_pathstyles.jl b/test/test_pathstyles.jl index 8a47f062f..93de92f37 100644 --- a/test/test_pathstyles.jl +++ b/test/test_pathstyles.jl @@ -279,7 +279,9 @@ end terminate!(pa4; rounding=1.9, overlay_index=1) cf = Cell{Float64}("test") render!(cf, pa4, GDSMeta()) - @test length(flatten(cf).elements) == 35 + # Zero-length subsegments left around overlay terminations used to emit three + # degenerate zero-area rectangles (35 elements); they now expand to nothing (#269). + @test length(flatten(cf).elements) == 32 @test bounds(cf) == Rectangle{Float64}((-6.0, -16.0), (26.0, 16.0)) pa5 = Path(0, 0) diff --git a/test/test_render.jl b/test/test_render.jl index 0daa504b4..26a6fad7f 100644 --- a/test/test_render.jl +++ b/test/test_render.jl @@ -1369,3 +1369,79 @@ end @test DeviceLayout.Curvilinear._assert_open_segment(open_seg) === nothing end end + +@testitem "Zero-length turns are ignored (#269)" setup = [CommonTestSetup] begin + # A zero-length turn has coincident endpoints, which used to trip the + # closed-segment check meant for full turns. Zero-length continuous-style + # nodes carry no geometry and expand to nothing instead. + function zpath(sty) + pa = Path(0.0μm, 0.0μm) + straight!(pa, 100μm, sty) + turn!(pa, 0.0°, 95.5μm) + straight!(pa, 100μm) + return pa + end + + # Per-node expansion returns nothing instead of throwing + pa = zpath(Paths.SimpleTrace(10μm)) + @test isempty(pathtopolys(pa.nodes[2])) + + # Rendering the whole path ignores the zero-length turn + c = Cell("zeroturn", nm) + render!(c, zpath(Paths.SimpleTrace(10μm)), GDSMeta(0)) + @test length(c.elements) == 2 + + c2 = Cell("zeroturncpw", nm) + render!(c2, zpath(Paths.SimpleCPW(10μm, 6μm)), GDSMeta(0)) + @test length(c2.elements) == 4 + + # Zero-length subsegments inside a CompoundSegment: matching CompoundStyle + # (per-subsegment zip) and a plain style pinned over the compound segment + pc = zpath(Paths.SimpleTrace(10μm)) + simplify!(pc) + @test length(pathtopolys(pc.nodes[1])) == 2 + @test length(vcat(pathtopolys(pc.nodes[1].seg, Paths.SimpleTrace(10μm)))) == 2 +end + +@testitem "NoRender loops don't produce empty polygons" setup = [CommonTestSetup] begin + import DeviceLayout: NoRender, Plain, StyleDict, points + import DeviceLayout: CurvilinearPolygon, CurvilinearRegion + + r = Rectangle(10.0μm, 10.0μm) + hole = Rectangle(2.0μm, 2.0μm) + Point(4.0μm, 4.0μm) + + # A NoRender loop inside a Rounded style expands to nothing, not to a + # zero-point Polygon (which cannot be written to GDS). + @test isempty(to_polygons(Polygons.Rounded(1.0μm)(NoRender()(r)))) + @test isempty(to_polygons(Polygons.Rounded(0.0μm)(NoRender()(r)))) + @test isempty( + to_polygons( + Polygons.Rounded(0.5μm)(DeviceLayout.optional_entity(r, :flag; default=false)) + ) + ) + + # NoRender on a StyleDict contour under Rounded + sd = StyleDict() + sd[1] = NoRender() + @test isempty(to_polygons(Polygons.Rounded(0.5μm)(sd(union2d(r))))) + + # A NoRender hole is subtracting nothing; the exterior still renders + sd_hole = StyleDict() + sd_hole[1] = Plain() + sd_hole[1, 1] = NoRender() + plgs = to_polygons(Polygons.Rounded(0.5μm)(sd_hole(difference2d(r, hole)))) + @test length(plgs) == 1 + @test !isempty(points(only(plgs))) + + # Unstyled discretization of the empty sentinel and empty-exterior region + T = typeof(1.0μm) + @test isempty(to_polygons(CurvilinearPolygon(Point{T}[]))) + @test isempty(to_polygons(CurvilinearRegion{T}(CurvilinearPolygon(Point{T}[])))) + + # End to end: rendering and saving a cell with NoRender-styled entities works + c = Cell("norender", nm) + render!(c, Polygons.Rounded(1.0μm)(NoRender()(r)), GDSMeta(0)) + render!(c, Polygons.Rounded(0.5μm)(sd_hole(difference2d(r, hole))), GDSMeta(1)) + @test all(p -> !isempty(points(p)), c.elements) + save(joinpath(mktempdir(), "norender.gds"), c) +end