Skip to content

Commit ec9ce6a

Browse files
authored
Use curvature-based tolerance for ellipse (#96)
* Use curvature-based tolerance for ellipse * Test SolidModel ellipse keywords * Fix scaling in discretization_grid * Fix SchematicLogger deepcopy issue * Make sure endpoints are not too close together * Deprecate circle
1 parent 6f1a4fd commit ec9ce6a

File tree

10 files changed

+186
-41
lines changed

10 files changed

+186
-41
lines changed

src/DeviceLayout.jl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ import .Polygons:
372372
StyleDict,
373373
addstyle!,
374374
circle,
375+
circle_polygon,
375376
clip,
376377
cliptree,
377378
difference2d,
@@ -395,6 +396,7 @@ export Polygons,
395396
StyleDict,
396397
addstyle!,
397398
circle,
399+
circle_polygon,
398400
clip,
399401
cliptree,
400402
difference2d,

src/polygons.jl

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import IntervalSets.endpoints
6565

6666
export Polygon, ClippedPolygon, Ellipse, Circle
6767
export circle,
68+
circle_polygon,
6869
clip,
6970
cliptree,
7071
circularapprox,
@@ -169,14 +170,52 @@ function convert(::Type{Ellipse{T}}, e::Ellipse{S}) where {T, S}
169170
return Ellipse{T}(convert(Point{T}, e.center), convert(NTuple{2, T}, e.radii), e.angle)
170171
end
171172

172-
function to_polygons(e::Ellipse; Δθ=5°, kwargs...)
173-
r =
174-
θ ->
175-
(e.radii[1] * e.radii[2]) /
176-
sqrt((e.radii[2] * cos(θ))^2 + (e.radii[1] * sin(θ))^2)
177-
# θ₀ = atand(e.axis[2], e.axis[1])°
178-
p = θ -> e.center + r- e.angle) .* Point(cos(θ), sin(θ))
179-
return Polygon(p.(0:Δθ:(360° - Δθ)))
173+
"""
174+
ellipse_curvature(e::Ellipse, θ)
175+
176+
Compute the curvature of an ellipse at parameter θ.
177+
For an ellipse with semi-major axis `a` and semi-minor axis `b`,
178+
the curvature is κ(φ) = ab / (a²sin²φ + b²cos²φ)^(3/2)
179+
where φ is the angle measured from the major axis of the ellipse.
180+
181+
Since θ in the ellipse parameterization is measured from the global x-axis,
182+
we need φ = θ - e.angle to get the angle relative to the ellipse's major axis.
183+
"""
184+
function ellipse_curvature(e::Ellipse, θ)
185+
a, b = e.radii[1], e.radii[2] # a is major axis, b is minor axis
186+
φ = θ - e.angle # Convert from global angle θ to ellipse-relative angle φ
187+
return (a * b) / (a^2 * sin(φ)^2 + b^2 * cos(φ)^2)^1.5
188+
end
189+
190+
function to_polygons(
191+
e::Ellipse;
192+
atol=DeviceLayout.onenanometer(eltype(e.center)),
193+
Δθ=nothing,
194+
kwargs...
195+
)
196+
if !isnothing(Δθ) # Use Δθ-based discretization
197+
r =
198+
θ ->
199+
(e.radii[1] * e.radii[2]) /
200+
sqrt((e.radii[2] * cos(θ))^2 + (e.radii[1] * sin(θ))^2)
201+
p = θ -> e.center + r- e.angle) .* Point(cos(θ), sin(θ))
202+
return Polygon(p.(0:Δθ:(360° - Δθ)))
203+
else # Use tolerance-based discretization
204+
curvature_fn = θ -> ellipse_curvature(e, θ)
205+
# t_scale is used to approximately convert "t" (θ) to arclength, use the larger radius to be safe
206+
θs = DeviceLayout.discretization_grid(
207+
curvature_fn,
208+
atol,
209+
(0.0, 2π);
210+
t_scale=e.radii[1]
211+
)
212+
r =
213+
θ ->
214+
(e.radii[1] * e.radii[2]) /
215+
sqrt((e.radii[2] * cos(θ))^2 + (e.radii[1] * sin(θ))^2)
216+
p = θ -> e.center + r- e.angle) .* Point(cos(θ), sin(θ))
217+
return Polygon(p.(θs[1:(end - 1)])) # Don't duplicate last point
218+
end
180219
end
181220

182221
DeviceLayout.magnify(e::Ellipse, mag) = Ellipse(mag .* e.center, mag .* e.radii, e.angle)
@@ -481,11 +520,20 @@ function perimeter(e::Ellipse)
481520
end
482521

483522
"""
484-
circle(r, α=10°)
523+
circle_polygon(r, Δθ=10°)
485524
486-
Return a circular `Polygon` centered about the origin with radius `r` and angular step `α`.
525+
Return a circular `Polygon` centered about the origin with radius `r` and angular step `Δθ`.
487526
"""
488-
circle(r, α=10°) = Polygon(r .* (a -> Point(cos(a), sin(a))).(0:α:(360° - α)))
527+
circle_polygon(r, Δθ=10°) = Polygon(r .* (a -> Point(cos(a), sin(a))).(0:Δθ:(360° - Δθ)))
528+
function circle(r, α=10°)
529+
@warn """"
530+
`circle(r, α)` is deprecated. Use `Circle(r)` or `Circle(center, r)` to create an \
531+
exact circle that will be discretized at render time according to rendering keyword \
532+
`atol` (default 1nm) or `Δθ` (if provided). To construct the polygon directly, use \
533+
`circle_polygon(r, α)`.
534+
"""
535+
return circle_polygon(r, α)
536+
end
489537

490538
"""
491539
struct Rounded{T <: Coordinate} <: GeometryEntityStyle

src/schematics/schematics.jl

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -467,22 +467,36 @@ mutable struct SchematicLogger <: AbstractLogger
467467
stage::Symbol
468468
logname::String
469469
logger::TeeLogger
470+
end
470471

471-
function SchematicLogger(name; path="build", level=Logging.Info)
472-
logname, logger = if isnothing(path)
473-
"output", Base.NullLogger()
474-
else
475-
mkpath(path)
476-
filename = joinpath(path, name * ".log")
477-
filename, MinLevelLogger(FormatLogger(_format_log, filename), level)
478-
end
479-
return new(
480-
Dict{Symbol, LogLevel}(),
481-
:plan,
482-
logname,
483-
TeeLogger(current_logger(), logger)
484-
)
472+
function SchematicLogger(name; path="build", level=Logging.Info)
473+
logname, logger = if isnothing(path)
474+
"output", Base.NullLogger()
475+
else
476+
mkpath(path)
477+
filename = joinpath(path, name * ".log")
478+
filename, MinLevelLogger(FormatLogger(_format_log, filename), level)
485479
end
480+
return SchematicLogger(
481+
Dict{Symbol, LogLevel}(),
482+
:plan,
483+
logname,
484+
TeeLogger(current_logger(), logger)
485+
)
486+
end
487+
488+
Base.copy(x::SchematicLogger) = SchematicLogger(
489+
copy(x.max_level_logged),
490+
x.stage,
491+
x.logname,
492+
TeeLogger(x.logger.loggers...)
493+
)
494+
# Needed for an edge case where a `current_logger` was not copyable
495+
function Base.deepcopy_internal(x::SchematicLogger, stackdict::IdDict)
496+
haskey(stackdict, x) && return stackdict[x]
497+
y = copy(x)
498+
stackdict[x] = y
499+
return y
486500
end
487501

488502
# Overrides required for custom logger

src/solidmodels/render.jl

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,21 @@ function to_primitives(
9494
return flat
9595
end
9696

97-
to_primitives(::SolidModel, ent::Ellipse; rounded=true, Δθ=360° / 8, kwargs...) =
98-
rounded ? ent : to_polygons(ent; Δθ)
97+
function to_primitives(::SolidModel, ent::Ellipse; rounded=nothing, Δθ=nothing, kwargs...)
98+
if !isnothing(rounded)
99+
Base.depwarn(
100+
"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`",
101+
:to_primitives
102+
)
103+
rounded && return ent # Keep as ellipse primitive
104+
# Otherwise, use the old default Δθ for backward compatibility
105+
return to_polygons(ent; Δθ=(isnothing(Δθ) ? 360° / 8 : Δθ), kwargs...)
106+
else
107+
isnothing(Δθ) && return ent # Keep as ellipse primitive (default code path)
108+
# Otherwise, use specified Δθ
109+
return to_polygons(ent; Δθ, kwargs...) # Discretize to polygon
110+
end
111+
end
99112

100113
# Path nodes that can be drawn with native curves (in OCC)
101114
# Gmsh does have its own native curves but we don't use them (the APIs and particularly

src/utils.jl

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ function discretization_grid(
216216
bnds::Tuple{Float64, Float64}=(0.0, 1.0);
217217
t_scale=1.0
218218
)
219-
dt = 0.01
219+
dt = 0.01 * bnds[2]
220220
ts = zeros(4000)
221221
ts[1] = bnds[1]
222222
t = bnds[1]
@@ -229,7 +229,7 @@ function discretization_grid(
229229
)
230230
t = ts[i - 1]
231231
# Set dt based on distance from chord assuming constant curvature
232-
if cc >= 100 * 8 * tolerance / t_scale^2 # Update dt if curvature is not near zero
232+
if cc >= 100 * 8 * tolerance / (bnds[2]^2 * t_scale^2) # Update dt if curvature is not near zero
233233
dt = uconvert(NoUnits, sqrt(8 * tolerance / cc) / t_scale)
234234
end
235235
if t + dt >= bnds[2]
@@ -246,6 +246,9 @@ function discretization_grid(
246246
ts[i] = min(bnds[2], t + dt)
247247
t = ts[i]
248248
end
249-
249+
# Make sure last two points aren't unnecessarily close together
250+
if ts[i - 1] > (ts[i] + ts[i - 2]) / 2
251+
ts[i - 1] = (ts[i] + ts[i - 2]) / 2
252+
end
250253
return ts[1:i]
251254
end

test/coverage/Project.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
[deps]
2+
DeviceLayout = "ebf59a4a-04ec-49d7-8cd4-c9382ceb8e85"
23
LocalCoverage = "5f6e1e16-694c-5876-87ef-16b5274f298e"
34
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"

test/test_clipping.jl

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ import DeviceLayout.Polygons: circularapprox, circularequality
163163
@test length(ip) == 1
164164
@test ip[1] == Polygon(Point{Int}[(2.0, 1.0), (1.0, 1.0), (1.0, 0.0), (2.0, 0.0)])
165165

166-
c = circle(1, 30°)
166+
c = circle_polygon(1, 30°)
167167
f = θ -> Point(cosd(θ), sind(θ))
168168
ptrue = f.(0:30:330)
169169
@test circularapprox(points(c), ptrue)
@@ -293,7 +293,7 @@ import DeviceLayout.Polygons: circularapprox, circularequality
293293
Point{T}[(2.0μm, 1.0μm), (1.0μm, 1.0μm), (1.0μm, 0.0μm), (2.0μm, 0.0μm)]
294294
)
295295

296-
c = circle(1.0μm, 30°)
296+
c = circle_polygon(1.0μm, 30°)
297297
f = θ -> Point(T(cosd(θ)), T(sind(θ)))
298298
ptrue = f.(0:30:330)
299299
@test circularapprox(points(c), ptrue)
@@ -330,7 +330,7 @@ import DeviceLayout.Polygons: circularapprox, circularequality
330330
@test u == union2d([u], r1)
331331
@test u == union2d(r1, [u])
332332

333-
c = circle(1, 1°)
333+
c = circle_polygon(1, 1°)
334334
u = union2d(c, c)
335335
@test u == union2d(c, [c])
336336
@test u == union2d([c], c)
@@ -426,25 +426,25 @@ import DeviceLayout.Polygons: circularapprox, circularequality
426426
for CASE = 3:7
427427
if CASE == 3 # works
428428
inner = Rectangle(10μm, 10μm)
429-
outer = circle(50μm, 1°)
429+
outer = circle_polygon(50μm, 1°)
430430
elseif CASE == 4 # works
431431
inner = Rectangle(10μm, 10μm) |> Translation(0μm, 10μm)
432-
outer = circle(50μm, 1°)
432+
outer = circle_polygon(50μm, 1°)
433433
elseif CASE == 5 # DOESN'T work
434434
inner = Rectangle(10μm, 10μm) |> Translation(10μm, 0μm)
435-
outer = circle(50μm, 1°)
435+
outer = circle_polygon(50μm, 1°)
436436
elseif CASE == 6 # works
437437
inner = Rectangle(10μm, 10μm) |> Translation(10μm, 0μm)
438-
outer = circle(50μm, 60°)
438+
outer = circle_polygon(50μm, 60°)
439439
elseif CASE == 7 # DOESN'T work
440440
inner = Rectangle(10μm, 10μm) |> Translation(10μm, 0μm)
441-
outer = circle(50μm, 45°)
441+
outer = circle_polygon(50μm, 45°)
442442
end
443443
diff = to_polygons(difference2d(outer, inner))
444444
@test length(diff[1].p) > length(outer.p)
445445
end
446446

447-
rout = circle(100μm, π / 50)
447+
rout = circle_polygon(100μm, π / 50)
448448
rin = centered(Rectangle(10μm, 10μm))
449449
pcb_outline = to_polygons(difference2d(rout, rin))[1]
450450
@test length(pcb_outline.p) > length(rout.p)

test/test_schematic_solidmodel.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ function test_component(component, clip_area, mesh=false, gui=false)
132132
# Should not render to the solid model.
133133
render!(
134134
floorplan.coordinate_system,
135-
not_solidmodel(circle(5μm, 1°)),
135+
not_solidmodel(circle_polygon(5μm, 1°)),
136136
SemanticMeta(:circle)
137137
)
138138

test/test_shapes.jl

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ rng = MersenneTwister(1337)
1313
@test length(points((elements(c)[1]))) == 9
1414

1515
# check that polygons aren't rounded if min_angle is set
16-
poly = Polygons._round_poly(circle(10μm, 5°), 0.5μm, min_angle=10 / 180 * π)
16+
poly = Polygons._round_poly(circle_polygon(10μm, 5°), 0.5μm, min_angle=10 / 180 * π)
1717
@test length(points(poly)) == 72
1818

1919
@test Polygons._round_poly(Rectangle(10.0, 10.0), 1.0) isa Polygon
@@ -480,6 +480,66 @@ end
480480

481481
@test_nowarn place!(cs, er, GDSMeta())
482482
@test_nowarn render!(c, cs)
483+
484+
# Test tolerance-based discretization (new default)
485+
e_default = to_polygons(e) # Should use atol by default
486+
e_delta_style = to_polygons(e; Δθ=5°) # Δθ-based approach
487+
488+
# The new tolerance-based approach should produce more points than 5° steps
489+
@test length(points(e_default)) > length(points(e_delta_style))
490+
491+
# Test that atol parameter works
492+
e_coarse = to_polygons(e; atol=0.1μm) # Very coarse tolerance
493+
e_fine = to_polygons(e; atol=1.0nm) # Very fine tolerance
494+
495+
# Coarse tolerance should produce fewer points than fine tolerance
496+
@test length(points(e_coarse)) < length(points(e_fine))
497+
498+
# If tolerance is too high the discretization will get too scared to update dt from 1%
499+
# It's OK if an improvement to the discretization algorithm renders this test obsolete
500+
# But currently that's the correct thing to do
501+
e_too_coarse = to_polygons(e; atol=1.0μm)
502+
@test length(points(e_too_coarse)) > length(points(e_coarse))
503+
504+
# Test backward compatibility - Δθ should still work when explicitly provided
505+
e_10deg = to_polygons(e; Δθ=10°)
506+
e_5deg = to_polygons(e; Δθ=5°)
507+
508+
# 10° steps should produce fewer points than 5° steps
509+
@test length(points(e_10deg)) < length(points(e_5deg))
510+
511+
# Test that both atol and Δθ can be provided (Δθ should take precedence for backward compatibility)
512+
e_mixed = to_polygons(e; atol=1.0nm, Δθ=10°)
513+
@test length(points(e_mixed)) == length(points(e_10deg))
514+
515+
# Test with circles (special case of ellipse)
516+
circ = Circle(Point(0.0μm, 0.0μm), 1.0μm)
517+
circ_default = to_polygons(circ)
518+
circ_delta = to_polygons(circ; Δθ=5.12°) # default atol gives ~5.12° spacing on this circle
519+
circ_delta_coarse = to_polygons(circ; Δθ=10°)
520+
521+
# Delta method also takes away last point if it's closer than Δθ to the end, so n1=n2+1
522+
@test length(points(circ_default)) == length(points(circ_delta)) + 1
523+
@test length(points(circ_default)) > length(points(circ_delta_coarse))
524+
# Discretization should be very similar to circular_arc
525+
@test length(points(circ_default)) == length(circular_arc(2pi, 1.0μm, 1.0nm)) - 1 # last pt duplicated
526+
527+
# Make sure error is as small as tolerance says
528+
area(p) =
529+
sum(
530+
(gety.(p.p) + gety.(circshift(p.p, -1))) .*
531+
(getx.(p.p) - getx.(circshift(p.p, -1)))
532+
) / 2
533+
e_fine = to_polygons(e; atol=0.1nm)
534+
p = to_polygons(difference2d(e_fine, e_default))[1]
535+
@test abs(area(p) / perimeter(p)) < 1nm # on average better than 1nm
536+
537+
# Last two points are not too close together
538+
p = points(to_polygons(e, atol=60nm))
539+
@test norm(p[1] - p[end]) > norm(p[end] - p[end - 1]) / 2
540+
541+
# circle is deprecated
542+
@test_logs (:warn, r"deprecated") circle(10.0)
483543
end
484544

485545
@testset "Sweeping" begin

test/test_solidmodel.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,10 @@ import DeviceLayout.SolidModels.STP_UNIT
466466
place!(cs, e, SemanticMeta(:test))
467467
sm = SolidModel("test"; overwrite=true)
468468
render!(sm, cs)
469+
@test SolidModels.to_primitives(sm, e) === e
470+
@test SolidModels.to_primitives(sm, e; rounded=true) === e
471+
@test length(points(SolidModels.to_primitives(sm, e; rounded=false))) == 8
472+
@test length(points(SolidModels.to_primitives(sm, e; Δθ=pi / 2))) == 4
469473

470474
# CurvilinearPolygon
471475
# A basic, noncurved polygon

0 commit comments

Comments
 (0)