Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions docs/src/concepts/polygons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/src/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@
```@docs
Circle
Ellipse
Polygons.iscircle
```

See [Shapes](./shapes.md).
Expand Down
8 changes: 6 additions & 2 deletions src/curve_recovery.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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}) =
Expand Down Expand Up @@ -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

Expand Down
33 changes: 31 additions & 2 deletions src/curvilinear.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1465,14 +1486,22 @@ 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 ||
(expanded isa AbstractVector && any(x -> x isa AbstractPolygon, expanded))
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...)
Expand Down
33 changes: 33 additions & 0 deletions src/polygons.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions test/test_curve_recovery.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion test/test_examples.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions test/test_shapes.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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, π)
Expand Down
Loading