Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/concepts/geometry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion docs/src/concepts/polygons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -205,6 +205,7 @@
### [Shapes](@id api-shapes)

```@docs
AbstractEllipse
Circle
Ellipse
```
Expand Down
2 changes: 2 additions & 0 deletions src/DeviceLayout.jl
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export Rectangles, Rectangle, height, isproper, width
include("polygons.jl")
import .Polygons:
Polygon,
AbstractEllipse,
Ellipse,
LineSegment,
Circle,
Expand Down Expand Up @@ -402,6 +403,7 @@ import .Polygons:
xor2d_layerwise
export Polygons,
Polygon,
AbstractEllipse,
Ellipse,
LineSegment,
Circle,
Expand Down
7 changes: 5 additions & 2 deletions src/curve_recovery.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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}) =
Expand Down Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions src/curvilinear.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
131 changes: 104 additions & 27 deletions src/polygons.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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°)
Expand All @@ -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°)
Expand All @@ -168,43 +178,77 @@ 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`,
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

Expand All @@ -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...
Expand All @@ -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)
Expand Down Expand Up @@ -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

"""
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading