diff --git a/CHANGELOG.md b/CHANGELOG.md index f68966c1f..6f903d1ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ 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. + - `Circle` is now its own type `Circle{T} <: AbstractEllipse{T}` (with + `center` and `r` fields) instead of a constructor alias for an equal-radii `Ellipse`. Rendered + geometry is unchanged within tolerance, and angle-preserving transformations now return a `Circle`. + Code relying on `Circle(...) isa Ellipse` or on `.radii`/`.angle` direct field access of `Circle` + results **will no longer work** and should use the new `AbstractEllipse` supertype and the `center`/`r1`/`r2`/`angle`/`radius` accessors. + - `Circle` participates in curve recovery: it is represented exactly as four 90° arcs + (via a new `CurvilinearPolygon(::Circle)` constructor), so `union2d_curved` and the + other curve-preserving boolean operations recover circular arcs instead of warning + and discretizing. - 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/geometry.md b/docs/src/concepts/geometry.md index 609ea0839..9b83fb09f 100644 --- a/docs/src/concepts/geometry.md +++ b/docs/src/concepts/geometry.md @@ -15,7 +15,7 @@ Here's a type hierarchy with the most important types for geometry representatio ``` AbstractGeometry{S<:Coordinate} ├── GeometryEntity (basic "shapes") - │ ├── Polygon, Rectangle, Text, Ellipse... + │ ├── Polygon, Rectangle, Text, Ellipse, Circle... │ ├── ClippedPolygon (result of polygon [clipping](./polygons.md#Clipping) — `union2d`, etc.) │ ├── Paths.Node (one segment+style pair in a Path) │ └── StyledEntity (entity + rounding or other rendering customization) diff --git a/docs/src/concepts/polygons.md b/docs/src/concepts/polygons.md index 8d3e41e92..45e01aee9 100644 --- a/docs/src/concepts/polygons.md +++ b/docs/src/concepts/polygons.md @@ -75,7 +75,9 @@ 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` 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 diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index c260074c2..f1bc2416e 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -205,6 +205,7 @@ ### [Shapes](@id api-shapes) ```@docs + AbstractEllipse Circle Ellipse ``` diff --git a/src/DeviceLayout.jl b/src/DeviceLayout.jl index 43e76e924..5c0b7dc5d 100644 --- a/src/DeviceLayout.jl +++ b/src/DeviceLayout.jl @@ -371,6 +371,7 @@ export Rectangles, Rectangle, height, isproper, width include("polygons.jl") import .Polygons: Polygon, + AbstractEllipse, Ellipse, LineSegment, Circle, @@ -402,6 +403,7 @@ import .Polygons: xor2d_layerwise export Polygons, Polygon, + AbstractEllipse, Ellipse, LineSegment, Circle, diff --git a/src/curve_recovery.jl b/src/curve_recovery.jl index e6e315ec7..511e901b0 100644 --- a/src/curve_recovery.jl +++ b/src/curve_recovery.jl @@ -181,6 +181,8 @@ 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. +_normalize_curved_clip_arg(p::Circle) = [CurvilinearPolygon(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 +234,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`), `Circle` (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..0382f642d 100644 --- a/src/curvilinear.jl +++ b/src/curvilinear.jl @@ -130,6 +130,21 @@ 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`. Quarter +# arcs keep every curve strictly shorter than π on every consumer path. +function CurvilinearPolygon(c::Circle{T}) where {T} + p = [ + c.center + Point(c.r, zero(c.r)), + c.center + Point(zero(c.r), c.r), + c.center - Point(c.r, zero(c.r)), + c.center - Point(zero(c.r), c.r) + ] + curves = [Paths.Turn(90.0°, c.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( @@ -1483,6 +1498,10 @@ to_curvilinear(ents::AbstractVector, sty; kwargs...) = to_curvilinear(ent::AbstractPolygon, sty; kwargs...) = styled_loop(convert(Polygon, ent), sty; kwargs...) to_curvilinear(ent::CurvilinearPolygon, sty; kwargs...) = styled_loop(ent, sty; kwargs...) +# 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. +to_curvilinear(ent::Circle, sty; kwargs...) = + to_curvilinear(CurvilinearPolygon(ent), sty; kwargs...) # Path nodes expand through `pathtopolys`, which preserves curves; geometry-transparent # styles pass through. Without this method a `MeshSized` path node falls to the generic # `to_polygons` fallback and silently discretizes its arcs. Zero-length continuous-style diff --git a/src/polygons.jl b/src/polygons.jl index 162f3705d..bf890fc27 100644 --- a/src/polygons.jl +++ b/src/polygons.jl @@ -65,7 +65,7 @@ import IntervalTrees: IntervalTree, IntervalValue import IntervalSets.(..) import IntervalSets.endpoints -export Polygon, ClippedPolygon, Ellipse, Circle, LineSegment +export Polygon, ClippedPolygon, AbstractEllipse, Ellipse, Circle, LineSegment export circle, circle_polygon, clip, @@ -139,7 +139,17 @@ isapprox(p1::Polygon, p2::Polygon; kwargs...) = isapprox(p1.p, p2.p; kwargs...) copy(p::Polygon) = Polygon(copy(p.p)) """ - struct Ellipse{T} <: GeometryEntity{T} + abstract type AbstractEllipse{T} <: GeometryEntity{T} + +Elliptical entities. Subtypes implement the accessors `center`, `r1` (major axis +radius), `r2` (minor axis radius) and `angle` (major axis angle, CCW from the positive +x-axis), through which the shared methods (`to_polygons`, `perimeter`, rendering, ...) +are defined. +""" +abstract type AbstractEllipse{T} <: GeometryEntity{T} end + +""" + struct Ellipse{T} <: AbstractEllipse{T} center::Point{T} radii::NTuple{2, T} angle::typeof(1.0°) @@ -151,7 +161,7 @@ copy(p::Polygon) = Polygon(copy(p.p)) Represent an ellipse with a centroid, radii and major axis angle. The major axis radius is stored first within `radii`, and the axis angle is defined CCW from the positive x-axis. """ -struct Ellipse{T} <: GeometryEntity{T} +struct Ellipse{T} <: AbstractEllipse{T} center::Point{T} radii::NTuple{2, T} angle::typeof(1.0°) @@ -168,31 +178,65 @@ Ellipse(center::Point; r::Coordinate) = Ellipse(center, (r, r), 0.0°) copy(e::Ellipse) = Ellipse(e.center, e.radii, e.angle) """ + struct Circle{T} <: AbstractEllipse{T} + center::Point{T} + r::T + end Circle(center::Point, r::Coordinate) Circle(r::Coordinate) -Construct an Ellipse with major and minor radii equal to `r` at `center` (or origin if not provided). +Represent a circle with radius `r` at `center` (or the origin if not provided). + +Transformations that preserve angles (rotations, translations, reflections, uniform +scaling) return a `Circle`; a general `Transformation` may return an `Ellipse`. """ -Circle(center::Point{T}, r::T) where {T} = Ellipse(center, (r, r), 0.0°) +struct Circle{T} <: AbstractEllipse{T} + center::Point{T} + r::T +end Circle(r::T) where {T <: Coordinate} = Circle(zero(Point{T}), r) function Circle(center::Point{S}, r::T) where {S, T} V = float(promote_type(S, T)) return Circle(convert(Point{V}, center), convert(V, r)) end +copy(c::Circle) = Circle(c.center, c.r) + center(e::Ellipse) = e.center r1(e::Ellipse) = e.radii[1] r2(e::Ellipse) = e.radii[2] angle(e::Ellipse) = e.angle +center(c::Circle) = c.center +r1(c::Circle) = c.r +r2(c::Circle) = c.r +angle(::Circle) = 0.0° + +""" + radius(c::Circle) + +The radius of the circle. +""" +radius(c::Circle) = c.r + +lowerleft(c::Circle) = c.center - Point(c.r, c.r) +upperright(c::Circle) = c.center + Point(c.r, c.r) + convert(::Type{GeometryEntity{T}}, e::Ellipse) where {T} = convert(Ellipse{T}, e) convert(::Type{GeometryEntity{T}}, e::Ellipse{T}) where {T} = e function convert(::Type{Ellipse{T}}, e::Ellipse{S}) where {T, S} return Ellipse{T}(convert(Point{T}, e.center), convert(NTuple{2, T}, e.radii), e.angle) end +convert(::Type{GeometryEntity{T}}, c::Circle) where {T} = convert(Circle{T}, c) +convert(::Type{GeometryEntity{T}}, c::Circle{T}) where {T} = c +function convert(::Type{Circle{T}}, c::Circle{S}) where {T, S} + return Circle{T}(convert(Point{T}, c.center), convert(T, c.r)) +end +convert(::Type{Ellipse{T}}, c::Circle) where {T} = + Ellipse{T}(convert(Point{T}, c.center), (convert(T, c.r), convert(T, c.r)), 0.0°) """ - ellipse_curvature(e::Ellipse, θ) + ellipse_curvature(e::AbstractEllipse, θ) Compute the curvature of an ellipse at parameter θ. For an ellipse with semi-major axis `a` and semi-minor axis `b`, @@ -200,11 +244,11 @@ the curvature is κ(φ) = ab / (a²sin²φ + b²cos²φ)^(3/2) where φ is the angle measured from the major axis of the ellipse. Since θ in the ellipse parameterization is measured from the global x-axis, -we need φ = θ - e.angle to get the angle relative to the ellipse's major axis. +we need φ = θ - angle(e) to get the angle relative to the ellipse's major axis. """ -function ellipse_curvature(e::Ellipse, θ) - a, b = e.radii[1], e.radii[2] # a is major axis, b is minor axis - φ = θ - e.angle # Convert from global angle θ to ellipse-relative angle φ +function ellipse_curvature(e::AbstractEllipse, θ) + a, b = r1(e), r2(e) # a is major axis, b is minor axis + φ = θ - angle(e) # Convert from global angle θ to ellipse-relative angle φ return (a * b) / (a^2 * sin(φ)^2 + b^2 * cos(φ)^2)^1.5 end @@ -219,8 +263,8 @@ function _ellipse_p(θ, θ1, a, b) end function to_polygons( - e::Ellipse; - atol=DeviceLayout.onenanometer(eltype(e.center)), + e::AbstractEllipse; + atol=DeviceLayout.onenanometer(eltype(center(e))), Δθ=nothing, rtol=nothing, kwargs... @@ -233,11 +277,27 @@ function to_polygons( Base.Fix1(ellipse_curvature, e), atol, (0.0, 2π); - t_scale=e.radii[1], + t_scale=r1(e), rtol=rtol )[1:(end - 1)]) end - return Polygon([e.center + _ellipse_p(θ, e.angle, e.radii[1], e.radii[2]) for θ in θs]) + return Polygon([center(e) + _ellipse_p(θ, angle(e), r1(e), r2(e)) for θ in θs]) +end + +function to_polygons( + c::Circle{T}; + Δθ=nothing, + atol=DeviceLayout.onenanometer(T), + rtol=nothing, + kwargs... +) where {T} + !isnothing(Δθ) && return circle_polygon(c.r, Δθ) + c.center + if !isnothing(rtol) + atol = max(atol, rtol * abs(radius(c))) + end + return Polygon( + DeviceLayout.circular_arc(2pi, radius(c), atol; center=center(c))[1:(end - 1)] + ) end DeviceLayout.magnify(e::Ellipse, mag) = Ellipse(mag .* e.center, mag .* e.radii, e.angle) @@ -281,25 +341,40 @@ function transform(e::Ellipse{T}, f::ScaledIsometry) where {T} end end -function transform(e::Ellipse, f::Transformation) +# Angle-preserving transformations keep a Circle a Circle. +DeviceLayout.magnify(c::Circle, mag) = Circle(mag .* c.center, mag * c.r) +DeviceLayout.rotate(c::Circle, rot) = Circle(Rotation(rot)(c.center), c.r) +DeviceLayout.reflect_across_xaxis(c::Circle) = Circle(Reflection(0)(c.center), c.r) +DeviceLayout.translate(c::Circle{T}, tra::Point{T}) where {T} = + Circle(Translation(tra)(c.center), c.r) +DeviceLayout.reflect_across_line(c::Circle, dir; through_pt=nothing) = + Circle(Reflection(dir; through_pt=through_pt)(c.center), c.r) +DeviceLayout.reflect_across_line(c::Circle, p0, p1) = + Circle(Reflection(p0, p1)(c.center), c.r) + +function transform(c::Circle{T}, f::ScaledIsometry) where {T} + return Circle(f(c.center), mag(f) * c.r) +end + +function transform(e::AbstractEllipse, f::Transformation) preserves_angles(f) && return transform(e, ScaledIsometry(f)) # Assemble the origin, major and minor end points, apply transforms to them. - a, b = e.radii - p1 = e.center + Rotation(e.angle)(Point(a, zero(a))) - p2 = e.center + Rotation(e.angle + 90°)(Point(b, zero(b))) - center, p1, p2 = f.([e.center, p1, p2]) + a, b = r1(e), r2(e) + p1 = center(e) + Rotation(angle(e))(Point(a, zero(a))) + p2 = center(e) + Rotation(angle(e) + 90°)(Point(b, zero(b))) + center_, p1, p2 = f.([center(e), p1, p2]) # center -> p1 and center -> p2 are no longer orthogonal (angles aren't preserved). - new_major = p1 - center - new_minor = p2 - center + new_major = p1 - center_ + new_minor = p2 - center_ M = ustrip([new_major.x new_minor.x; new_major.y new_minor.y]) cov = M * M' vals, vecs = eigen(cov) # real valued @assert vals[2] >= vals[1] θ = ((atand(vecs[2, 2], vecs[1, 2]) + 180) % 180)° - return Ellipse(center, (sqrt(vals[2]), sqrt(vals[1])) .* oneunit(a), θ) + return Ellipse(center_, (sqrt(vals[2]), sqrt(vals[1])) .* oneunit(a), θ) end """ @@ -549,14 +624,16 @@ function perimeter(p::ClippedPolygon{T}) where {T} end """ - perimeter(poly::Ellipse) + perimeter(poly::AbstractEllipse) -Approximate (Euclidean) perimeter of an `Ellipse` using Ramanujan's approximation formula +Approximate (Euclidean) perimeter of an ellipse using Ramanujan's approximation formula https://arxiv.org/pdf/math/0506384.pdf + +Exact for a `Circle` (`2πr`). """ -function perimeter(e::Ellipse) - a = maximum(e.radii) - b = minimum(e.radii) +function perimeter(e::AbstractEllipse) + a = r1(e) + b = r2(e) return π * ((a + b) + 3 * (a - b)^2 / (10 * (a + b) + sqrt(a^2 + 14 * a * b + b^2))) end diff --git a/src/solidmodels/render.jl b/src/solidmodels/render.jl index 2f8fb2258..94fc99f1d 100644 --- a/src/solidmodels/render.jl +++ b/src/solidmodels/render.jl @@ -81,7 +81,13 @@ to_primitives( kwargs... ) where {T} = to_polygons(ent.ent, ent.sty; kwargs...) -function to_primitives(::SolidModel, ent::Ellipse; rounded=nothing, Δθ=nothing, kwargs...) +function to_primitives( + ::SolidModel, + ent::AbstractEllipse; + rounded=nothing, + Δθ=nothing, + kwargs... +) if !isnothing(rounded) Base.depwarn( "The `rounded` keyword for Ellipse is deprecated. Use `Δθ=nothing` (default) to keep as ellipse primitive, or `Δθ=some_angle` to discretize to polygon. For the same discretization as `rounded=false` with `Δθ` not specified, use `Δθ=360°/8`", @@ -98,7 +104,7 @@ function to_primitives(::SolidModel, ent::Ellipse; rounded=nothing, Δθ=nothing end function to_primitives( ::SolidModel{GmshNative}, - ent::Ellipse; + ent::AbstractEllipse; rounded=nothing, Δθ=nothing, kwargs... @@ -183,7 +189,7 @@ end ######## Ellipse function _add_to_current_solidmodel!( - e::Ellipse{T}, + e::AbstractEllipse{T}, m::Meta, k; zmap=(_) -> zero(T), @@ -662,14 +668,14 @@ end # Ellipse — sample its perimeter at h-spacing (Ramanujan perimeter estimate for # the sample count; the parametric point evaluation is exact). -function _sample_meshsize!(e::Ellipse, h::Float64, α::Float64, z::Float64) - a = ustrip(STP_UNIT, e.radii[1]) - b = ustrip(STP_UNIT, e.radii[2]) +function _sample_meshsize!(e::AbstractEllipse, h::Float64, α::Float64, z::Float64) + a = ustrip(STP_UNIT, r1(e)) + b = ustrip(STP_UNIT, r2(e)) perim = pi * (3 * (a + b) - sqrt((3a + b) * (a + 3b))) Ns = max(8, ceil(Int, perim / h)) - cx = ustrip(STP_UNIT, e.center.x) - cy = ustrip(STP_UNIT, e.center.y) - θ = ustrip(uconvert(°, e.angle)) * (π / 180) + cx = ustrip(STP_UNIT, center(e).x) + cy = ustrip(STP_UNIT, center(e).y) + θ = ustrip(uconvert(°, angle(e))) * (π / 180) cs = cos(θ) sn = sin(θ) for i = 0:(Ns - 1) diff --git a/src/solidmodels/solidmodels.jl b/src/solidmodels/solidmodels.jl index 0481626c9..70481f78b 100644 --- a/src/solidmodels/solidmodels.jl +++ b/src/solidmodels/solidmodels.jl @@ -40,6 +40,7 @@ import DeviceLayout: CurvilinearRegion, GeometryEntity, GeometryEntityStyle, + AbstractEllipse, Ellipse, LineSegment, Meta, diff --git a/test/test_curve_recovery.jl b/test/test_curve_recovery.jl index 058a880b0..2b1fe4641 100644 --- a/test/test_curve_recovery.jl +++ b/test/test_curve_recovery.jl @@ -690,3 +690,53 @@ 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 + + c = Circle(Point(1.0μm, 2.0μm), 3.0μm) + + # 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 + + # Self-union recovers all four arcs exactly, with no curve-loss warning (a Circle has + # a curve-recovery method; its Ellipse equivalent 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..aac683db7 100644 --- a/test/test_shapes.jl +++ b/test/test_shapes.jl @@ -687,15 +687,92 @@ end # circle is deprecated @test_logs (:warn, r"deprecated") circle(10.0) - # Issue #228 - @test Circle(Point(1µm, 1µm), 1.0µm) isa Ellipse # was MethodError - @test Circle(Point(1.0µm, 1µm), 1µm) isa Ellipse - @test Circle(Point(1µm, 1µm), 1nm) isa Ellipse - @test Circle(Point(1µm, 1µm), 1.0nm) isa Ellipse + # Issue #228 (mixed-unit/type constructors; `isa Circle` since issue #251 — + # Circle used to construct an equal-radii Ellipse) + @test Circle(Point(1µm, 1µm), 1.0µm) isa Circle # was MethodError + @test Circle(Point(1.0µm, 1µm), 1µm) isa Circle + @test Circle(Point(1µm, 1µm), 1nm) isa Circle + @test Circle(Point(1µm, 1µm), 1.0nm) isa Circle + @test Circle(Point(1µm, 1µm), 1.0µm) isa AbstractEllipse + @test !(Circle(Point(1µm, 1µm), 1.0µm) isa Ellipse) @test Ellipse(Point(1µm, 1µm); r=1.0µm) isa Ellipse @test Ellipse(Point(1µm, 1µm); r=1nm) isa Ellipse end +@testitem "Circle type" setup = [CommonTestSetup] begin + import DeviceLayout: + transform, + magnify, + rotate, + translate, + reflect_across_xaxis, + reflect_across_line, + lowerleft, + upperright + c = Circle(Point(1.0μm, 2.0μm), 3.0μm) + e = Ellipse(Point(1.0μm, 2.0μm), (3.0μm, 3.0μm), 0.0°) + + @testset "Accessors and conversion" begin + @test Polygons.center(c) == Point(1.0μm, 2.0μm) + @test Polygons.r1(c) == Polygons.r2(c) == Polygons.radius(c) == 3.0μm + @test Polygons.angle(c) == 0.0° + @test convert(Ellipse{typeof(1.0μm)}, c) == e + cnm = convert(Circle{typeof(1.0nm)}, c) + @test cnm isa Circle && Polygons.radius(cnm) == 3000.0nm + @test copy(c) == c + end + + @testset "Angle-preserving transformations preserve Circle" 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 tc isa Circle + end + @test Rotation(90°)(c).center ≈ Point(-2.0μm, 1.0μm) + @test magnify(c, 2).r == 6.0μm + # Non-angle-preserving transformations widen to Ellipse + shear = Transformations.LinearMap(Transformations.@SMatrix [2 0; 0 1]) + @test shear(c) isa Ellipse + @test shear(c).center == shear(e).center + @test shear(c).radii == shear(e).radii + @test shear(c).angle == shear(e).angle + end + + @testset "Fast paths match the generic path" 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)) + @test Polygons.perimeter(c) == 2π * 3.0μm + # Discretized geometry is equivalent to a Circle within tolerance + # without excessive sampling + pts = points(to_polygons(c)) + midpts = (pts .+ circshift(pts, 1)) / 2 + @test maximum(abs.(radius(c) .- norm.(midpts .- center(c)))) < 1nm + @test all(isapprox.(abs.(radius(c) .- norm.(midpts .- center(c))), 1nm, rtol=0.15)) + # relax tolerance + pts_10nm = points(to_polygons(c; atol=10nm)) + midpts_10nm = (pts_10nm .+ circshift(pts_10nm, 1)) / 2 + @test maximum(abs.(radius(c) .- norm.(midpts_10nm .- center(c)))) < 10nm + @test all( + isapprox.(abs.(radius(c) .- norm.(midpts_10nm .- center(c))), 10nm, rtol=0.15) + ) + # rtol + pts_30nm = points(to_polygons(c; rtol=1e-2)) + midpts_30nm = (pts_30nm .+ circshift(pts_30nm, 1)) / 2 + @test maximum(abs.(radius(c) .- norm.(midpts_30nm .- center(c)))) < 30nm + @test all( + isapprox.(abs.(radius(c) .- norm.(midpts_30nm .- center(c))), 30nm, rtol=0.15) + ) + end +end + @testitem "circular_arc equal angles" setup = [CommonTestSetup] begin # When θ1 = θ2, circular_arc should return a single point, not nothing. θ = convert(Float64, π) diff --git a/test/test_solidmodel.jl b/test/test_solidmodel.jl index cd82fc569..247ff704b 100644 --- a/test/test_solidmodel.jl +++ b/test/test_solidmodel.jl @@ -1705,3 +1705,22 @@ end @test area ≈ expected rtol = 1e-6 end end + +@testitem "Circle renders like an equal-radii Ellipse (#251)" setup = [CommonTestSetup] begin + # Circle is kept as an ellipse primitive by to_primitives and must produce the + # same OpenCASCADE surface as the equal-radii Ellipse it replaced. + function render_one(ent) + cs = CoordinateSystem("circ", nm) + place!(cs, ent, SemanticMeta(:disk)) + sm = SolidModel("circ"; overwrite=true) + render!(sm, cs, zmap=(_) -> 0.0μm) + tags = SolidModels.entitytags(sm["disk", 2]) + return sum(SolidModels.gmsh.model.occ.getMass(2, t) for t in tags; init=0.0), + length(tags) + end + a_circ, n_circ = render_one(Circle(Point(1.0μm, 2.0μm), 3.0μm)) + a_ell, n_ell = render_one(Ellipse(Point(1.0μm, 2.0μm), (3.0μm, 3.0μm), 0.0°)) + @test n_circ == n_ell == 1 + @test a_circ ≈ a_ell rtol = 1e-12 + @test a_circ ≈ π * 3.0^2 rtol = 1e-9 +end