diff --git a/CHANGELOG.md b/CHANGELOG.md index f68966c1f..0393bea7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,16 @@ The format of this changelog is based on full turn is represented by two 180° curves rather than one 360° curve. Closed segments reaching the degenerate corner-point checks now throw an `ArgumentError` instead of silently dropping a curve. + - Circles participate in curve recovery (issue #251): an `Ellipse` with exactly equal + radii (e.g. constructed via `Circle`) is represented exactly as four 90° arcs, so + `union2d_curved` and the other curve-preserving boolean operations recover circular + arcs instead of warning and discretizing. The new `iscircle` predicate documents the + exactness contract: equality is guaranteed for `Circle(...)` results and preserved by + angle-preserving transformations. + - Circle discretization now uses uniform sampling at the tolerance-derived angular step + (`circular_arc`) instead of the general curvature-controlled kernel, and circles take + a fast bounding-box path with no discretization. Rendered point counts and positions + for circles change slightly within the same `atol`/`rtol` tolerances. - Added `WithDirection <: GeometryEntityStyle` to annotate geometry entities with a direction (CCW from +x in local frame). The direction transforms with the entity under rotations and reflections, allowing extraction of the final global direction for use in simulation configuration. - Added `SolidModels.check_port_connectivity`, using `SolidModels.connected_components` to report ports as `:open`, `:short`, `:floating`, or `:missing` - Added `detect_non_boundary_contacts=false` keyword argument to `SolidModels.connected_components`; when `true`, 1d edges embedded in the interior of 2D surfaces (like the feet of staple air bridges) will be treated as connecting diff --git a/docs/src/concepts/polygons.md b/docs/src/concepts/polygons.md index 8d3e41e92..e3f218429 100644 --- a/docs/src/concepts/polygons.md +++ b/docs/src/concepts/polygons.md @@ -75,17 +75,20 @@ Curve-bearing inputs are expanded to their exact arc geometry before clipping vi converter the `SolidModel` render path uses, so `Rounded` applied to `Polygon`, `Rectangle`, `ClippedPolygon`, `CurvilinearRegion`, and `CurvilinearPolygon`, as well as nestings with no-op styles (`MeshSized`, `WithDirection`) and per-contour `StyleDict`s — including on -`Path` nodes — all recover their arcs where the footprint survives. +`Path` nodes — all recover their arcs where the footprint survives. A circle (an `Ellipse` +with exactly equal radii, e.g. from `Circle`) is represented exactly as +four 90° arcs, so circles participate in curve recovery too (an ellipse with unequal radii +is not representable as arcs and still discretizes). **Current limitations:** A curve is recovered only if its entire discretized run survives the boolean operation with exact integer equality. If the operation cuts through a curve (e.g., a straight edge crossing an arc's interior), that curve falls back to a polyline. Partial-curve recovery is not supported. -An input entity with no curve-recovery method (for example an `Ellipse`, or a style -combination not listed above) is discretized via `to_polygons` with no provenance, so any -curves it carries cannot be recovered; a warning is logged once per entity type when this -happens. +An input entity with no curve-recovery method (for example an `Ellipse` with unequal +radii, or a style combination not listed above) is discretized via `to_polygons` with no +provenance, so any curves it carries cannot be recovered; a warning is logged once per +entity type when this happens. ## Styles diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index c260074c2..dfc5f12cf 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -207,6 +207,7 @@ ```@docs Circle Ellipse + Polygons.iscircle ``` See [Shapes](./shapes.md). diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index e6e315ec7..11b2f825d 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -181,6 +181,9 @@ end # Normalize a clip operand into a flat Vector of entities, preserving curve-bearing # entities intact (unlike _normalize_clip_arg, which discretizes via to_polygons). _normalize_curved_clip_arg(p::DeviceLayout.GeometryEntity) = [p] +# Circles convert to their exact four-arc form so their curves are recoverable; +# unequal-radii ellipses have no exact arc form and discretize (with a loss warning). +_normalize_curved_clip_arg(p::Ellipse) = iscircle(p) ? [CurvilinearPolygon(p)] : [p] _normalize_curved_clip_arg(p::AbstractArray) = collect(Iterators.flatten(_normalize_curved_clip_arg.(p))) _normalize_curved_clip_arg(p::Union{GeometryStructure, GeometryReference}) = @@ -232,8 +235,9 @@ corresponding clipping operation: a `GeometryEntity` or array of `GeometryEntity Curve-bearing entities have their curves tracked through discretization and recovered in the result: `CurvilinearPolygon`, `CurvilinearRegion`, `Path` nodes (e.g. `Turn`/`BSpline` -segments rendered with a `Style`), and `Rounded`-styled `Polygon`/`Rectangle`. All other -entities are discretized via `to_polygons`. +segments rendered with a `Style`), equal-radii `Ellipse`s (represented exactly as four 90° +arcs), and `Rounded`-styled `Polygon`/`Rectangle`. All other entities are discretized via +`to_polygons`. ## Keyword arguments diff --git a/src/curvilinear.jl b/src/curvilinear.jl index 21d3d38c4..507c3533e 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -34,7 +34,7 @@ import DeviceLayout: isapprox_angle import DeviceLayout: MeshSized, WithDirection, OptionalStyle, Plain, NoRender using DeviceLayout.Paths -import DeviceLayout.Polygons: cornerindices, StyleDict, Rounded +import DeviceLayout.Polygons: cornerindices, iscircle, StyleDict, Rounded import DeviceLayout.Polygons.Clipper: PolyNode, contour import Unitful: uconvert, °, Length @@ -130,6 +130,27 @@ function CurvilinearPolygon(points::Vector{Point{T}}) where {T} return CurvilinearPolygon{T}(points, Paths.Segment[], Int[]) end CurvilinearPolygon(p::Polygon{T}) where {T} = CurvilinearPolygon(points(p)) +# A circle as four 90° CCW arcs meeting at the axis-aligned extreme points. Four arcs +# rather than one or two: a single 360° curve collapses in the duplicate-endpoint dedup +# above (its lone vertex pairs with itself under `circshift`), and 180° arcs hit the OCC +# semicircle split path plus the collinear-endpoint guard in `add_circle_arc`. +function CurvilinearPolygon(e::Ellipse{T}) where {T} + iscircle(e) || throw( + ArgumentError( + "an Ellipse with unequal radii is not exactly representable as arcs; " * + "only circles (see `iscircle`) convert to CurvilinearPolygon" + ) + ) + r = e.radii[1] + p = [ + e.center + Point(r, zero(r)), + e.center + Point(zero(r), r), + e.center - Point(r, zero(r)), + e.center - Point(zero(r), r) + ] + curves = [Paths.Turn(90.0°, r; p0=p[i], α0=i * 90.0°) for i = 1:4] + return CurvilinearPolygon{T}(p, curves, [1, 2, 3, 4]) +end ### Conversion methods function to_polygons( @@ -1465,7 +1486,9 @@ styled_loop(::CurvilinearPolygon{T}, ::NoRender; kwargs...) where {T} = # (single style applied to the whole entity) and dispatch by entity type below. # To AbstractPolygon, CurvilinearPolygon, CurvilinearRegion, or vector of those. # If this produces an AbstractPolygon and the styled entity should be curve-bearing, warn for curve loss. -function to_curvilinear(ent::GeometryEntity, sty; kwargs...) +to_curvilinear(ent::GeometryEntity, sty; kwargs...) = + _to_curvilinear_discretize(ent, sty; kwargs...) +function _to_curvilinear_discretize(ent, sty; kwargs...) expanded = to_polygons(ent, sty; kwargs...) discretized = expanded isa AbstractPolygon || @@ -1473,6 +1496,12 @@ function to_curvilinear(ent::GeometryEntity, sty; kwargs...) discretized && _maybe_warn_curve_loss(sty(ent)) return expanded end +# A circle is exactly representable as four arcs, so styled circles keep their curves +# (and participate in curve recovery) instead of falling to the discretizing fallback. +# Unequal-radii ellipses are not representable as arcs and discretize as before. +to_curvilinear(ent::Ellipse, sty; kwargs...) = + iscircle(ent) ? to_curvilinear(CurvilinearPolygon(ent), sty; kwargs...) : + _to_curvilinear_discretize(ent, sty; kwargs...) # Nested styles: expand the inner style first (inner-out), then apply the outer style to the # resulting curvilinear geometry so both rounding passes see exact arcs. function to_curvilinear(ent::StyledEntity, sty; kwargs...) diff --git a/src/polygons.jl b/src/polygons.jl index 162f3705d..891609fe8 100644 --- a/src/polygons.jl +++ b/src/polygons.jl @@ -172,6 +172,10 @@ copy(e::Ellipse) = Ellipse(e.center, e.radii, e.angle) Circle(r::Coordinate) Construct an Ellipse with major and minor radii equal to `r` at `center` (or origin if not provided). + +The result satisfies [`iscircle`](@ref Polygons.iscircle), so it discretizes with the constant-curvature +fast path and participates in curve recovery (see [`recover_curves`](@ref)) as an exact +four-arc representation. """ Circle(center::Point{T}, r::T) where {T} = Ellipse(center, (r, r), 0.0°) Circle(r::T) where {T <: Coordinate} = Circle(zero(Point{T}), r) @@ -180,6 +184,20 @@ function Circle(center::Point{S}, r::T) where {S, T} return Circle(convert(Point{V}, center), convert(V, r)) end +""" + iscircle(e::Ellipse) + +Whether `e` is a circle, meaning its radii are exactly (bitwise) equal. + +Exact equality is guaranteed for ellipses constructed with `Circle` and is preserved by +angle-preserving transformations (translation, rotation, reflection, uniform scaling), +which apply identical operations to both radii. An ellipse whose radii were computed +independently and merely agree approximately is not treated as a circle: circles take +exact representations (four 90° arcs in curve recovery) and fast paths, which are only +valid when the radii are exactly equal. +""" +iscircle(e::Ellipse) = e.radii[1] == e.radii[2] + center(e::Ellipse) = e.center r1(e::Ellipse) = e.radii[1] r2(e::Ellipse) = e.radii[2] @@ -228,6 +246,15 @@ function to_polygons( if !isnothing(Δθ) # Use Δθ-based discretization θs = ((0.0°):Δθ:(360° - Δθ)) else # Use tolerance-based discretization + if iscircle(e) # Constant curvature: sample uniformly at the sagitta-bounded step + r = e.radii[1] + if !isnothing(rtol) + atol = max(atol, rtol * abs(r)) + end + return Polygon( + DeviceLayout.circular_arc(2pi, r, atol; center=e.center)[1:(end - 1)] + ) + end # t_scale is used to approximately convert "t" (θ) to arclength, use the larger radius to be safe θs = (DeviceLayout.discretization_grid( Base.Fix1(ellipse_curvature, e), @@ -496,6 +523,12 @@ end DeviceLayout.lowerleft(x::Polygon) = lowerleft(x.p) DeviceLayout.upperright(x::Polygon) = upperright(x.p) + +# A circle's bounding box is center ± r with no discretization needed. +DeviceLayout.lowerleft(e::Ellipse) = + iscircle(e) ? e.center - Point(e.radii[1], e.radii[1]) : lowerleft(to_polygons(e)) +DeviceLayout.upperright(e::Ellipse) = + iscircle(e) ? e.center + Point(e.radii[1], e.radii[1]) : upperright(to_polygons(e)) DeviceLayout.footprint(x::Polygon) = x # ClippedPolygon footprint = outer contour if there's only one diff --git a/test/test_curve_recovery.jl b/test/test_curve_recovery.jl index 058a880b0..3a328b464 100644 --- a/test/test_curve_recovery.jl +++ b/test/test_curve_recovery.jl @@ -690,3 +690,62 @@ end ) @test isempty(_curve_loss_warned) end + +@testitem "Curve recovery — circle four-arc representation (#251)" setup = [CommonTestSetup] begin + using DeviceLayout: union2d_curved, difference2d_curved, union2d, xor2d + using DeviceLayout: Rounded, MeshSized + using DeviceLayout.Curvilinear: + CurvilinearPolygon, + to_curvilinear, + _normalize_curved_clip_arg, + _curve_loss_warned, + iscircle + + c = Circle(Point(1.0μm, 2.0μm), 3.0μm) + @test iscircle(c) + + # Exact four-arc form: quarter turns meeting at the axis-aligned extreme points. + cp = CurvilinearPolygon(c) + @test cp.curve_start_idx == [1, 2, 3, 4] + @test all(t -> t isa Paths.Turn && t.α == 90.0° && t.r == 3.0μm, cp.curves) + # Every discretized point lies on the circle to within the default 1 nm tolerance. + @test all(points(to_polygons(cp))) do pt + return abs(norm(pt - Point(1.0μm, 2.0μm)) - 3.0μm) < 2nm + end + # Unitless coordinates work too. + @test length(CurvilinearPolygon(Circle(1.0)).curves) == 4 + # An unequal-radii ellipse has no exact arc form. + @test_throws ArgumentError CurvilinearPolygon( + Ellipse(Point(0.0μm, 0.0μm), (2.0μm, 1.0μm), 0.0°) + ) + + # Self-union recovers all four arcs exactly, with no curve-loss warning (a circle has + # an exact arc form; an unequal-radii ellipse warns and discretizes). + empty!(_curve_loss_warned) + report = [] + out = @test_logs min_level = Logging.Warn union2d_curved([c]; report) + @test length(out) == 1 + @test length(out[1].exterior.curves) == 4 + @test all(r -> r[1] == :recovered, report) + @test isempty(to_polygons(xor2d(out, cp))) + + # A clip cutting through two arcs: the untouched arcs recover, the cut ones fall + # back to polylines and are reported :clipped. + knife = Rectangle(Point(2.0μm, -2.0μm), Point(5.0μm, 6.0μm)) + report = [] + cut = difference2d_curved(c, knife; report) + @test length(cut) == 1 + @test length(cut[1].exterior.curves) == 2 + @test count(r -> r[1] == :recovered, report) == 2 + @test count(r -> r[1] == :clipped, report) == 2 + @test isempty(to_polygons(xor2d(cut, difference2d(cp, knife)))) + + # Geometry-transparent and no-op styles preserve the arcs: MeshSized passes through, + # and Rounded is a no-op on a circle (no straight-straight or line-arc corners). + empty!(_curve_loss_warned) + for ent in (MeshSized(1μm)(c), Rounded(1μm)(c)) + styled_out = @test_logs min_level = Logging.Warn union2d_curved(ent) + @test length(styled_out[1].exterior.curves) == 4 + @test isempty(to_polygons(xor2d(styled_out, cp))) + end +end diff --git a/test/test_examples.jl b/test/test_examples.jl index 04a9b784c..3f2d7693b 100644 --- a/test/test_examples.jl +++ b/test/test_examples.jl @@ -6,7 +6,7 @@ # Julia v1.10 and v1.11 give different fingerprints # Mainly for flagging unintentional changes, so doesn't need to run on every version fingerprint = Cells.geometry_fingerprint(artwork) - expected = "5349dc07d9ceabb83a3dd8ac7b100fa0fd41b251424a077112fb3a4c09473af5" + expected = "b79ba4e935f433696184d95749725c8736094002eb46ea628e80edbf37d49e83" @test fingerprint == expected fingerprint != expected && println(""" Expected QPU17 artwork fingerprint: $expected diff --git a/test/test_shapes.jl b/test/test_shapes.jl index 62f5cac97..9d622de3d 100644 --- a/test/test_shapes.jl +++ b/test/test_shapes.jl @@ -696,6 +696,64 @@ end @test Ellipse(Point(1µm, 1µm); r=1nm) isa Ellipse end +@testitem "Circles (#251)" setup = [CommonTestSetup] begin + import DeviceLayout: + magnify, + rotate, + translate, + reflect_across_xaxis, + reflect_across_line, + lowerleft, + upperright + import DeviceLayout.Polygons: iscircle + c = Circle(Point(1.0μm, 2.0μm), 3.0μm) + @test iscircle(c) + @test !iscircle(Ellipse(Point(0.0μm, 0.0μm), (2.0μm, 1.0μm), 0.0°)) + + @testset "Angle-preserving transformations preserve iscircle exactly" begin + for tc in ( + rotate(c, 30°), + translate(c, Point(1.0μm, 0.0μm)), + magnify(c, 2), + reflect_across_xaxis(c), + reflect_across_line(c, 45°), + reflect_across_line(c, Point(0.0μm, 0.0μm), Point(1.0μm, 1.0μm)), + Rotation(45°)(c), + (Translation(Point(1.0μm, 1.0μm)) ∘ Rotation(90°))(c) + ) + @test iscircle(tc) + end + @test magnify(c, 2).radii == (6.0μm, 6.0μm) + # Non-angle-preserving transformations produce a genuine ellipse + shear = Transformations.LinearMap(Transformations.@SMatrix [2 0; 0 1]) + @test !iscircle(shear(c)) + end + + @testset "Fast bounds" begin + @test lowerleft(c) == Point(-2.0μm, -1.0μm) + @test upperright(c) == Point(4.0μm, 5.0μm) + @test bounds(c) == Rectangle(Point(-2.0μm, -1.0μm), Point(4.0μm, 5.0μm)) + # Unequal radii fall back to the discretizing path + e = Ellipse(Point(0.0μm, 0.0μm), (2.0μm, 1.0μm), 0.0°) + @test lowerleft(e) == lowerleft(to_polygons(e)) + @test upperright(e) == upperright(to_polygons(e)) + end + + @testset "Discretization within tolerance without excessive sampling" begin + for (kwargs, tol) in ( + ((;), 1nm), # default atol + ((; atol=10nm), 10nm), + ((; rtol=1e-2), 30nm) # rtol * r = 30nm dominates the 1nm default atol + ) + pts = points(to_polygons(c; kwargs...)) + midpts = (pts .+ circshift(pts, 1)) / 2 + sagittas = abs.(3.0μm .- norm.(midpts .- Point(1.0μm, 2.0μm))) + @test maximum(sagittas) < tol + @test all(isapprox.(sagittas, tol, rtol=0.15)) + end + end +end + @testitem "circular_arc equal angles" setup = [CommonTestSetup] begin # When θ1 = θ2, circular_arc should return a single point, not nothing. θ = convert(Float64, π)