Skip to content

Commit dc062c5

Browse files
committed
Add more comments and tests for bspline approximation
1 parent 3e40c51 commit dc062c5

File tree

4 files changed

+78
-52
lines changed

4 files changed

+78
-52
lines changed

src/paths/segments/bspline_approximation.jl

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Arbitrary segment: Discretize according to approximation's curvature
1+
# Sample points used to estimate approximation error
22
function _testvals(f::Paths.Segment{T}, f_approx::Paths.BSpline) where {T}
33
l = Paths.pathlength(f)
44
tgrid = DeviceLayout.discretization_grid(f, _default_curve_atol(T))
@@ -10,6 +10,7 @@ function _testvals(f::Paths.Segment{T}, f_approx::Paths.BSpline) where {T}
1010
return tgrid, exact, normal, offsets
1111
end
1212

13+
# For offset segments, sample non-offset curve, plus normals and offset values
1314
function _testvals(f::Paths.OffsetSegment{T}, f_approx::Paths.BSpline) where {T}
1415
l = Paths.pathlength(f)
1516
tgrid = DeviceLayout.discretization_grid(f, _default_curve_atol(T))
@@ -24,8 +25,7 @@ end
2425

2526
# Offset bsplines use tgrid directly to calculate a bit faster
2627
function _testvals(f::Paths.GeneralOffset{T, BSpline{T}}, f_approx::Paths.BSpline) where {T}
27-
h(t) = Paths.Interpolations.hessian(f.seg.r, t)[1]
28-
tgrid = DeviceLayout.discretization_grid(h, _default_curve_atol(T))
28+
tgrid = DeviceLayout.discretization_grid(f.seg, _default_curve_atol(T))
2929
sgrid = t_to_arclength.(Ref(f.seg), tgrid)
3030
offsets = abs.(getoffset.(Ref(f), sgrid))
3131
# Don't actually calculate the exact curve, we'll only check that the offsets are correct
@@ -40,8 +40,7 @@ function _testvals(
4040
f::Paths.ConstantOffset{T, BSpline{T}},
4141
f_approx::Paths.BSpline
4242
) where {T}
43-
h(t) = Paths.Interpolations.hessian(f.seg.r, t)[1]
44-
tgrid = DeviceLayout.discretization_grid(h, _default_curve_atol(T))
43+
tgrid = DeviceLayout.discretization_grid(f.seg, _default_curve_atol(T))
4544
off = abs(getoffset(f))
4645
# Don't actually calculate the exact curve, we'll only check that the offset is correct
4746
exact = f.seg.r.(tgrid)
@@ -58,8 +57,12 @@ function _split_testvals(testvals, seg::Segment{T}) where {T}
5857
tgrid, exact, normal, offset = testvals
5958
t_half = _t_halflength(seg)
6059
idx_h2 = findfirst(t -> t > t_half, tgrid)
61-
isnothing(idx_h2) &&
62-
error("B-spline approximation of $seg is not converging; try increasing `atol`.")
60+
isnothing(idx_h2) && # segment is so short there are no points in test discretization
61+
@error """
62+
B-spline approximation of $seg failed to converge.
63+
Check curve for cusps and self-intersections, which may cause approximation to fail.
64+
Otherwise, increase `atol` to relax tolerance.
65+
"""
6366
t_h1 = tgrid[1:(idx_h2 - 1)]
6467
t_h2 = tgrid[idx_h2:end]
6568
new_tgrid_h1 = t_h1 / t_half
@@ -154,7 +157,11 @@ function bspline_approximation(
154157
split_tv = [testvals]
155158
while err > atol
156159
if refine > maxits
157-
@warn "Maximum error $err > tolerance $atol with $(refine-1) refinement iterations. Increase `maxits` to refine further or increase `atol` to relax tolerance."
160+
@warn """
161+
Maximum error $err > tolerance $atol after $(refine-1) refinement iterations.
162+
Check curve $f for cusps and self-intersections, which may cause approximation to fail.
163+
Increase `maxits` or manually split path to refine further, or increase `atol` to relax tolerance.
164+
"""
158165
break
159166
end
160167
err = 0.0 * oneunit(T)

src/paths/segments/compound.jl

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ function subsegment(s::CompoundSegment{T}, t) where {T}
4242
t < l1 && return seg, t - l0
4343
l0 = l1
4444
end
45-
return last(s.segments), t - l0
45+
return last(s.segments), t - l0 + pathlength(last(s.segments))
4646
end
4747

4848
function curvatureradius(s::CompoundSegment, t)
@@ -66,24 +66,17 @@ function (s::CompoundSegment{T})(t) where {T}
6666
a0 = p0(c[1])
6767
x = a0 + Point(D0x * t, D0y * t)
6868
return x::Point{R}
69+
elseif t > L
70+
h = c[end]
71+
h′ = ForwardDiff.derivative(h, pathlength(h))::Point{Float64}
72+
D1x, D1y = getx(h′), gety(h′)
73+
a = p1(c[end])
74+
x = a + Point(D1x * (t - L), D1y * (t - L))
75+
return x::Point{R}
6976
end
7077

71-
for i = 1:length(c)
72-
seg = c[i]
73-
l1 = l0 + pathlength(seg)
74-
if t < l1
75-
x = (seg)(t - l0)
76-
return x::Point{R}
77-
end
78-
l0 = l1
79-
end
80-
81-
h = c[end]
82-
h′ = ForwardDiff.derivative(h, pathlength(h))::Point{Float64}
83-
D1x, D1y = getx(h′), gety(h′)
84-
a = p1(c[end])
85-
x = a + Point(D1x * (t - L), D1y * (t - L))
86-
return x::Point{R}
78+
seg, dt = subsegment(s, t)
79+
return seg(dt)::Point{R}
8780
end
8881

8982
function _split(seg::CompoundSegment{T}, x, tag1=gensym(), tag2=gensym()) where {T}
@@ -135,10 +128,7 @@ end
135128
function direction(seg::CompoundSegment{T}, t) where {T}
136129
l0 = zero(T)
137130
t < l0 && return α0(seg.segments[1])
138-
for se in seg.segments
139-
l = l0 + pathlength(se)
140-
t < l && return direction(se, t - l0)
141-
l0 = l
142-
end
143-
return α1(seg.segments[end])
131+
s, dt = subsegment(seg, t)
132+
dt > pathlength(s) && return α1(s)
133+
return direction(s, dt)
144134
end

src/paths/segments/offset.jl

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ copy(s::OffsetSegment) = OffsetSegment(copy(s.seg), s.offset)
3535
getoffset(s::ConstantOffset, l...) = s.offset
3636
getoffset(s::GeneralOffset{T}) where {T} = l -> uconvert(unit(T), s.offset(l))
3737
getoffset(s::GeneralOffset, l) = getoffset(s)(l)
38-
getoffset(s::Segment{T}, l...) where {T} = zero(T)
3938
offset_derivative(seg::ConstantOffset, s) = 0.0
4039
offset_derivative(seg::GeneralOffset, s) = ForwardDiff.derivative(seg.offset, s)
4140

@@ -90,24 +89,32 @@ function curvatureradius(seg::ConstantOffset, s)
9089
end
9190

9291
function curvatureradius(seg::GeneralOffset, s)
93-
# This is not exactly right, particularly when the curvature is changing?
92+
# This one is only approximate for BSpline offsets
93+
# let s be seg.seg arclength, let t be seg arclength
94+
# tangent(seg, s) defined above is dseg/ds
95+
# ||d^2/dt^2 seg(s)|| = ||d/dt tangent(seg, s) ds/dt||
96+
# ||d/ds tangent(seg, s)|| ||ds/dt||^2
97+
# Where ||ds/dt|| = ||tangent(seg, s)|| = 1 for non-offset segments
98+
# But needs a correction for offsets
99+
# dt/ds = κ n # Sorry t is tangent for the rest of this comment
100+
# dn/ds = -κ t
101+
# || κ n + (d2_offset n - κ t d_offset) - d_offset κ t - offset κ^2 n - offset t dκ/ds ||
102+
94103
r = curvatureradius(seg.seg, s)
95-
reparam = 1 / (sqrt(1 + offset_derivative(seg, s)^2) * (1 - getoffset(seg, s) / r))
104+
reparam = 1 / (sqrt((1 - getoffset(seg, s) / r)^2 + offset_derivative(seg, s)^2))
96105
base_dir = direction(seg.seg, s)
97-
base_tangent = Point(cos(base_dir), sin(base_dir))
106+
base_tangent = Point(cos(base_dir), sin(base_dir)) # True for non-offset segments (arclength parameterized)
98107
base_normal = Point(-base_tangent.y, base_tangent.x)
99108

100-
dn = -(1 / r) * base_tangent
101-
ddn = -(1 / r)^2 * [base_normal.x, base_normal.y]
102-
103109
d_offset = offset_derivative(seg, s)
104110
d2_offset = ForwardDiff.derivative(s_ -> offset_derivative(seg, s_), s)
105111
d2_seg =
106112
(
107113
(1 / r) * base_normal +
108-
ddn * getoffset(seg, s) +
109-
2 * dn * d_offset +
110-
base_normal * d2_offset
114+
d2_offset * base_normal +
115+
-2 * (1 / r) * base_tangent * d_offset +
116+
-((1 / r) * base_normal) * ((1 / r) * getoffset(seg, s))
117+
# - getoffset(seg, s) * base_tangent * dκds # Nonzero for BSplines but basically fine to ignore
111118
) * reparam^2
112119
return sign(r) / norm(d2_seg)
113120
end

test/test_bspline.jl

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,27 +124,49 @@ end
124124
end
125125
end
126126
# Relaxed tolerance
127-
approx = Paths.bspline_approximation(curv[1].curves[1], atol=100nm)
127+
approx = Paths.bspline_approximation(curv[1].curves[1], atol=100.0nm)
128128
@test length(approx.segments) < 20
129129
# Non-offset curve approximation
130130
approx = Paths.bspline_approximation(pa[4].seg)
131131
@test length(approx.segments) < 9
132132
# Offset curvatureradius
133-
g_fd(c, s, ds=1.0nm) = (c(s + ds / 2) - c(s - ds / 2)) / ds
134-
h_fd(c, s, ds=1.0nm) = g_fd(s_ -> g_fd(c, s_, ds), s, ds)
135-
curvatureradius_fd(c, s, ds=1.0nm) = begin
133+
g_fd(c, s, ds=10.0nm) = (c(s + ds / 2) - c(s - ds / 2)) / ds
134+
h_fd(c, s, ds=10.0nm) = g_fd(s_ -> g_fd(c, s_, ds), s, ds)
135+
curvatureradius_fd(c, s, ds=10.0nm) = begin
136136
g = g_fd(c, s, ds)
137137
h = h_fd(c, s, ds)
138138
((g.x^2 + g.y^2)^(3 // 2)) / (g.x * h.y - g.y * h.x)
139139
end
140-
# Arbitrary curvature radius calculation is only approximate...
141-
c = curv[1].curves[1]
142-
@test abs(curvatureradius_fd(c, 10μm) - Paths.curvatureradius(c, 10μm)) < 100nm
143-
c = curv[3].curves[1]
144-
@test abs(curvatureradius_fd(c, 10μm) - Paths.curvatureradius(c, 10μm)) < 200nm
145-
c = curv[5].curves[1]
140+
# For BSplines, curvature radius calculation is only approximate, but not bad
141+
c = curv[1].curves[1] # ConstantOffset BSpline
142+
@test abs(curvatureradius_fd(c, 10μm) - Paths.curvatureradius(c, 10μm)) < 1nm
143+
c = curv[3].curves[1] # GeneralOffset BSpline
144+
@test abs(curvatureradius_fd(c, 10μm) - Paths.curvatureradius(c, 10μm)) < 50nm
145+
c = curv[5].curves[1] # GeneralOffset Turn
146+
# Paths.curvatureradius is exact for Turn offsets, the finite difference is wrong
146147
@test abs(curvatureradius_fd(c, 10μm) - Paths.curvatureradius(c, 10μm)) < 600nm
147-
c = curv[8].curves[1]
148+
approx = Paths.bspline_approximation(c, atol=100.0nm)
149+
pts = DeviceLayout.discretize_curve(c, 100.0nm)
150+
pts_approx = DeviceLayout.discretize_curve(approx, 100.0nm)
151+
area(p) =
152+
sum(
153+
(gety.(p.p) + gety.(circshift(p.p, -1))) .*
154+
(getx.(p.p) - getx.(circshift(p.p, -1)))
155+
) / 2
156+
p = Polygon([pts; reverse(pts_approx)])
157+
@test abs(area(p) / perimeter(p)) < 100nm # It's actually ~25nm but the guarantee is ~< tolerance
158+
c = curv[8].curves[1] # ConstantOffset Turn
148159
@test Paths.curvatureradius(c, 10μm) == sign(c.seg.α) * c.seg.r - c.offset
149160
@test curvatureradius_fd(c, 10μm) Paths.curvatureradius(c, 10μm) atol = 1nm
161+
162+
# Failure due to self-intersection
163+
pa2 = Path(Point(0.0, 0.0)nm, α0=90°)
164+
bspline!(
165+
pa2,
166+
[Point(-1000.0μm, 1000.0μm), Point(500.0μm, 500.0μm)],
167+
0°,
168+
Paths.SimpleCPW(20μm, 10μm)
169+
)
170+
cps = vcat(pathtopolys(pa2)...)
171+
@test_logs (:warn, r"Maximum error") Paths.bspline_approximation(cps[1].curves[1])
150172
end

0 commit comments

Comments
 (0)