sevgi-geometry 0.98.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -2
- data/README.md +26 -1
- data/lib/sevgi/geometry/element.rb +115 -61
- data/lib/sevgi/geometry/elements/arc.rb +256 -0
- data/lib/sevgi/geometry/elements/circle.rb +28 -0
- data/lib/sevgi/geometry/elements/ellipse/affine.rb +72 -0
- data/lib/sevgi/geometry/elements/ellipse/length.rb +80 -0
- data/lib/sevgi/geometry/elements/ellipse.rb +269 -0
- data/lib/sevgi/geometry/elements/line.rb +12 -12
- data/lib/sevgi/geometry/elements/parallelogram.rb +5 -7
- data/lib/sevgi/geometry/elements/polyline.rb +9 -0
- data/lib/sevgi/geometry/elements/rect.rb +5 -1
- data/lib/sevgi/geometry/elements/triangle.rb +6 -9
- data/lib/sevgi/geometry/equation/quadratic.rb +98 -16
- data/lib/sevgi/geometry/equation.rb +34 -16
- data/lib/sevgi/geometry/internal.rb +8 -0
- data/lib/sevgi/geometry/operation/align.rb +1 -4
- data/lib/sevgi/geometry/operation/box.rb +50 -0
- data/lib/sevgi/geometry/operation/sweep.rb +13 -15
- data/lib/sevgi/geometry/operation.rb +9 -8
- data/lib/sevgi/geometry/point.rb +12 -2
- data/lib/sevgi/geometry/predicate.rb +176 -0
- data/lib/sevgi/geometry/segment.rb +4 -2
- data/lib/sevgi/geometry/version.rb +1 -1
- data/lib/sevgi/geometry.rb +4 -3
- metadata +11 -4
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Geometry
|
|
5
|
+
# Finite, directed portion of an Ellipse, with less than one full turn.
|
|
6
|
+
# Positive extent is clockwise in screen coordinates. Zero extent has a single-point trace and draws nothing.
|
|
7
|
+
# @example Inspect and reverse an upper semicircle
|
|
8
|
+
# arc = Sevgi::Geometry::Arc[10, starting_angle: 180, extent: 180]
|
|
9
|
+
# arc.starting.deconstruct # => [-10.0, 0.0]
|
|
10
|
+
# arc.reverse.starting == arc.ending
|
|
11
|
+
class Arc < Element::Arced
|
|
12
|
+
# rubocop:disable Metrics/ParameterLists
|
|
13
|
+
|
|
14
|
+
# Builds a circular or elliptical arc from dimensions and angles.
|
|
15
|
+
# @param rx [Numeric] positive local x radius
|
|
16
|
+
# @param ry [Numeric] positive local y radius, defaulting to rx
|
|
17
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] parent ellipse center
|
|
18
|
+
# @param rotation [Numeric] clockwise ellipse rotation in degrees
|
|
19
|
+
# @param starting_angle [Numeric] local starting parameter angle in degrees
|
|
20
|
+
# @param extent [Numeric] signed extent strictly between -360 and 360 degrees
|
|
21
|
+
# @return [Sevgi::Geometry::Arc]
|
|
22
|
+
# @raise [Sevgi::Geometry::Error] when inputs violate the ellipse or angular invariants
|
|
23
|
+
def self.[](rx, ry = rx, extent:, position: Origin, rotation: 0, starting_angle: 0)
|
|
24
|
+
Ellipse[rx, ry, position:, rotation:].arc(starting_angle:, extent:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# rubocop:enable Metrics/ParameterLists
|
|
28
|
+
|
|
29
|
+
def self.close? = false
|
|
30
|
+
private_class_method :close?
|
|
31
|
+
|
|
32
|
+
# @return [Sevgi::Geometry::Ellipse] immutable parent ellipse
|
|
33
|
+
attr_reader :ellipse
|
|
34
|
+
# @return [Float] signed extent in degrees
|
|
35
|
+
attr_reader :extent
|
|
36
|
+
# @return [Float] local starting parameter angle in degrees
|
|
37
|
+
attr_reader :starting_angle
|
|
38
|
+
|
|
39
|
+
# Creates a finite arc. Use the bracket constructor or {Ellipse#arc}.
|
|
40
|
+
# @param ellipse [Sevgi::Geometry::Ellipse] parent ellipse
|
|
41
|
+
# @param starting_angle [Numeric] local starting parameter angle in degrees
|
|
42
|
+
# @param extent [Numeric] signed angular extent in degrees
|
|
43
|
+
# @return [void]
|
|
44
|
+
# @raise [Sevgi::Geometry::Error] when angles are not finite or extent reaches a full turn
|
|
45
|
+
def initialize(ellipse, starting_angle:, extent:)
|
|
46
|
+
super()
|
|
47
|
+
@ellipse = ellipse
|
|
48
|
+
@starting_angle = Real[:starting_angle, starting_angle]
|
|
49
|
+
@extent = Real[:extent, extent]
|
|
50
|
+
Error.("Arc extent must be between -360 and 360 degrees") unless @extent.abs < 360
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Rebuilds an arc from rounded canonical fields.
|
|
54
|
+
# @param precision [Integer, nil] decimal precision, or nil for the current function default
|
|
55
|
+
# @return [Sevgi::Geometry::Arc]
|
|
56
|
+
# @raise [Sevgi::Geometry::Error] when rounding makes a radius zero or extent a full turn
|
|
57
|
+
# @raise [Sevgi::ArgumentError] when precision is invalid
|
|
58
|
+
def approx(precision = nil)
|
|
59
|
+
ellipse
|
|
60
|
+
.approx(precision)
|
|
61
|
+
.arc(starting_angle: F.approx(starting_angle, precision), extent: F.approx(extent, precision))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns bounds of the finite trace without display rounding.
|
|
65
|
+
# @return [Sevgi::Geometry::Rect]
|
|
66
|
+
def box
|
|
67
|
+
points = [starting, ending, *ellipse.send(:extrema).select { contains_angle?(it) }.map { ellipse.point(it) }]
|
|
68
|
+
xs, ys = points.map(&:x), points.map(&:y)
|
|
69
|
+
Rect.from_corners([xs.min, ys.min], [xs.max, ys.max])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Reports whether the parent radii are exactly equal.
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def circular? = ellipse.circular?
|
|
75
|
+
|
|
76
|
+
# Reports whether the traversal is clockwise.
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def clockwise? = extent.positive?
|
|
79
|
+
|
|
80
|
+
# Reports whether the traversal is counterclockwise.
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
def counterclockwise? = extent.negative?
|
|
83
|
+
|
|
84
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
85
|
+
|
|
86
|
+
# Draws the finite trace as an SVG path using unrounded geometry.
|
|
87
|
+
# @param node [Object] graphics node receiving the path
|
|
88
|
+
# @param attributes [Hash] SVG attributes
|
|
89
|
+
# @return [Object] graphics command result
|
|
90
|
+
def draw(node, **attributes)
|
|
91
|
+
return node.path(d: "", **attributes) if empty?
|
|
92
|
+
return node.path(d: split_path, **attributes) if extent.abs > 180 && starting == ending
|
|
93
|
+
|
|
94
|
+
node.ArcTo(
|
|
95
|
+
x1: starting.x,
|
|
96
|
+
y1: starting.y,
|
|
97
|
+
x2: ending.x,
|
|
98
|
+
y2: ending.y,
|
|
99
|
+
rx:,
|
|
100
|
+
ry:,
|
|
101
|
+
rotation:,
|
|
102
|
+
large: extent.abs > 180,
|
|
103
|
+
sweep: clockwise?,
|
|
104
|
+
**attributes
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
109
|
+
|
|
110
|
+
# Reports whether angular extent is exactly zero, independently of numeric precision.
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def empty? = extent.zero?
|
|
113
|
+
|
|
114
|
+
# Returns the ending point.
|
|
115
|
+
# @return [Sevgi::Geometry::Point]
|
|
116
|
+
def ending = ellipse.point(ending_angle)
|
|
117
|
+
|
|
118
|
+
# Returns the unnormalized ending parameter angle.
|
|
119
|
+
# @return [Float]
|
|
120
|
+
def ending_angle = starting_angle + extent
|
|
121
|
+
|
|
122
|
+
# Returns the complete parent ellipse carrier.
|
|
123
|
+
# @return [Sevgi::Geometry::Equation::Quadratic]
|
|
124
|
+
def equation = ellipse.equation
|
|
125
|
+
|
|
126
|
+
# Returns the immutable parent carrier collection.
|
|
127
|
+
# @return [Array<Sevgi::Geometry::Equation::Quadratic>]
|
|
128
|
+
def equations = ellipse.equations
|
|
129
|
+
|
|
130
|
+
# Reports whether a point belongs to the open boundary.
|
|
131
|
+
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
132
|
+
# @return [Boolean]
|
|
133
|
+
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
134
|
+
def inside?(point) = on?(point)
|
|
135
|
+
|
|
136
|
+
# Returns the non-negative finite-trace length, independently of thread precision.
|
|
137
|
+
# @return [Float]
|
|
138
|
+
# @raise [Sevgi::Geometry::Error] when length is not finite or integration cannot meet its error target
|
|
139
|
+
def length = @length ||= empty? ? 0.0 : ellipse.send(:arc_length, starting_angle, extent)
|
|
140
|
+
|
|
141
|
+
# Reports membership using endpoint coordinate tolerance and the finite angular span.
|
|
142
|
+
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
143
|
+
# @return [Boolean]
|
|
144
|
+
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
145
|
+
def on?(point)
|
|
146
|
+
point = Tuple[Point, point]
|
|
147
|
+
return true if point.eq?(starting) || point.eq?(ending)
|
|
148
|
+
|
|
149
|
+
!empty? && ellipse.on?(point) && contains_angle?(ellipse.send(:parameter, point))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Returns the parent ellipse center, not the starting endpoint.
|
|
153
|
+
# @return [Sevgi::Geometry::Point]
|
|
154
|
+
def position = ellipse.position
|
|
155
|
+
|
|
156
|
+
# Returns an arc reflected across the selected axes.
|
|
157
|
+
# @param x [Boolean] reflect across the x-axis
|
|
158
|
+
# @param y [Boolean] reflect across the y-axis
|
|
159
|
+
# @return [Sevgi::Geometry::Arc]
|
|
160
|
+
# @raise [Sevgi::Geometry::Error] when flags are invalid
|
|
161
|
+
def reflect(x: true, y: true) = affine(:reflect, x:, y:)
|
|
162
|
+
|
|
163
|
+
# Reverses traversal without changing the finite trace.
|
|
164
|
+
# @return [Sevgi::Geometry::Arc]
|
|
165
|
+
def reverse = ellipse.arc(starting_angle: ending_angle, extent: -extent)
|
|
166
|
+
|
|
167
|
+
# Rotates the arc around the origin without changing local angles.
|
|
168
|
+
# @param angle [Numeric] clockwise angle in degrees
|
|
169
|
+
# @return [Sevgi::Geometry::Arc]
|
|
170
|
+
# @raise [Sevgi::Geometry::Error] when angle or resulting geometry is invalid
|
|
171
|
+
def rotate(angle) = ellipse.rotate(angle).arc(starting_angle:, extent:)
|
|
172
|
+
|
|
173
|
+
# Returns the parent ellipse rotation in degrees.
|
|
174
|
+
# @return [Float]
|
|
175
|
+
def rotation = ellipse.rotation
|
|
176
|
+
|
|
177
|
+
# Returns the parent local x radius.
|
|
178
|
+
# @return [Float]
|
|
179
|
+
def rx = ellipse.rx
|
|
180
|
+
|
|
181
|
+
# Returns the parent local y radius.
|
|
182
|
+
# @return [Float]
|
|
183
|
+
def ry = ellipse.ry
|
|
184
|
+
|
|
185
|
+
# Scales the arc from the origin, preserving its finite trace and traversal.
|
|
186
|
+
# @param sx [Numeric] x scale factor
|
|
187
|
+
# @param sy [Numeric, Sevgi::Undefined] y factor, defaulting to sx
|
|
188
|
+
# @return [Sevgi::Geometry::Arc]
|
|
189
|
+
# @raise [Sevgi::Geometry::Error] when the transform is singular or geometry is invalid
|
|
190
|
+
def scale(sx, sy = Undefined)
|
|
191
|
+
sx, sy = Real[:sx, sx], Real[:sy, Undefined.default(sy, sx)]
|
|
192
|
+
return ellipse.scale(sx, sy).arc(starting_angle:, extent:) if sx == sy
|
|
193
|
+
|
|
194
|
+
affine(:scale, sx, sy)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Skews the arc from the origin.
|
|
198
|
+
# @param ax [Numeric] x-axis skew angle in degrees
|
|
199
|
+
# @param ay [Numeric, Sevgi::Undefined] y-axis angle, defaulting to ax
|
|
200
|
+
# @return [Sevgi::Geometry::Arc]
|
|
201
|
+
# @raise [Sevgi::Geometry::Error] when the transform is singular or geometry is invalid
|
|
202
|
+
def skew(ax, ay = Undefined) = affine(:skew, ax, ay)
|
|
203
|
+
|
|
204
|
+
# Skews the arc along x.
|
|
205
|
+
# @param angle [Numeric] skew angle in degrees
|
|
206
|
+
# @return [Sevgi::Geometry::Arc]
|
|
207
|
+
# @raise [Sevgi::Geometry::Error] when angle or geometry is invalid
|
|
208
|
+
def skew_x(angle) = affine(:skew_x, angle)
|
|
209
|
+
|
|
210
|
+
# Skews the arc along y.
|
|
211
|
+
# @param angle [Numeric] skew angle in degrees
|
|
212
|
+
# @return [Sevgi::Geometry::Arc]
|
|
213
|
+
# @raise [Sevgi::Geometry::Error] when angle or geometry is invalid
|
|
214
|
+
def skew_y(angle) = affine(:skew_y, angle)
|
|
215
|
+
|
|
216
|
+
# Returns the starting point.
|
|
217
|
+
# @return [Sevgi::Geometry::Point]
|
|
218
|
+
def starting = ellipse.point(starting_angle)
|
|
219
|
+
|
|
220
|
+
# Returns a translated arc without changing local angles.
|
|
221
|
+
# @param dx [Numeric] x offset
|
|
222
|
+
# @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
|
|
223
|
+
# @return [Sevgi::Geometry::Arc]
|
|
224
|
+
# @raise [Sevgi::Geometry::Error] when offsets or resulting geometry are invalid
|
|
225
|
+
def translate(dx, dy = Undefined) = ellipse.translate(dx, dy).arc(starting_angle:, extent:)
|
|
226
|
+
|
|
227
|
+
alias center position
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def affine(...)
|
|
232
|
+
parent, phase, direction = ellipse.send(:affine, ...)
|
|
233
|
+
parent.arc(starting_angle: (direction * starting_angle) + phase, extent: direction * extent)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def contains_angle?(angle)
|
|
237
|
+
return false if empty?
|
|
238
|
+
|
|
239
|
+
difference = clockwise? ? angle - (starting_angle % 360) : (starting_angle % 360) - angle
|
|
240
|
+
difference % 360 <= extent.abs
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
244
|
+
def split_path
|
|
245
|
+
middle = ellipse.point(starting_angle + (extent / 2))
|
|
246
|
+
commands = [middle, ending].map do |point|
|
|
247
|
+
"A #{rx} #{ry} #{rotation} 0 #{clockwise? ? 1 : 0} #{point.x} #{point.y}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
["M #{starting.x} #{starting.y}", *commands].join(" ")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def state = [ellipse, starting_angle, extent]
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Geometry
|
|
5
|
+
# Complete circular boundary with a positive radius and a center.
|
|
6
|
+
# Uniform scaling preserves Circle. General affine transforms can return Ellipse.
|
|
7
|
+
# @example Create an upper semicircle from a circle
|
|
8
|
+
# Sevgi::Geometry::Circle[10].arc(starting_angle: 180, extent: 180)
|
|
9
|
+
class Circle < Ellipse
|
|
10
|
+
# Builds a circle from its radius and center.
|
|
11
|
+
# @param radius [Numeric] positive finite radius
|
|
12
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] circle center
|
|
13
|
+
# @return [Sevgi::Geometry::Circle]
|
|
14
|
+
# @raise [Sevgi::Geometry::Error] when radius or position is invalid
|
|
15
|
+
def self.[](radius, position: Origin) = new(radius, radius, position:, rotation: 0)
|
|
16
|
+
|
|
17
|
+
# Draws a native SVG circle without rounding the geometry.
|
|
18
|
+
# @param node [Object] graphics node receiving the circle
|
|
19
|
+
# @param attributes [Hash] SVG attributes
|
|
20
|
+
# @return [Object] graphics command result
|
|
21
|
+
def draw(node, **attributes) = node.circle(cx: position.x, cy: position.y, r: radius, **attributes)
|
|
22
|
+
|
|
23
|
+
# Returns the circle radius.
|
|
24
|
+
# @return [Float]
|
|
25
|
+
def radius = rx
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Geometry
|
|
5
|
+
class Ellipse
|
|
6
|
+
# Recovers ellipse axes and the Arc parameter correspondence after a linear transform.
|
|
7
|
+
# @api private
|
|
8
|
+
class Affine
|
|
9
|
+
def initialize(ellipse)
|
|
10
|
+
@ellipse = ellipse
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
|
14
|
+
def transform(method, *args, **kwargs)
|
|
15
|
+
axes = [Point[1, 0], Point[0, 1]].map { it.public_send(method, *args, **kwargs) }
|
|
16
|
+
direction = orientation(*axes)
|
|
17
|
+
center = @ellipse.position.public_send(method, *args, **kwargs)
|
|
18
|
+
u, v = basis.map { it.public_send(method, *args, **kwargs) }
|
|
19
|
+
ellipse = recover(u, v, center)
|
|
20
|
+
|
|
21
|
+
if @ellipse.is_a?(Circle) && similarity?(*axes)
|
|
22
|
+
ellipse = Circle[::Math.hypot(u.x, u.y), position: center]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
local = u.rotate(-ellipse.rotation)
|
|
26
|
+
phase = F.to_degrees(::Math.atan2(local.y / ellipse.ry, local.x / ellipse.rx))
|
|
27
|
+
[ellipse, phase, direction]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def basis
|
|
33
|
+
[Point[@ellipse.rx, 0], Point[0, @ellipse.ry]].map { it.rotate(@ellipse.rotation) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
37
|
+
def orientation(u, v)
|
|
38
|
+
scale = [u.x.abs, u.y.abs, v.x.abs, v.y.abs].max
|
|
39
|
+
Error.("Ellipse transform is singular") if scale.zero?
|
|
40
|
+
|
|
41
|
+
a, b = (u.x / scale) * (v.y / scale), (u.y / scale) * (v.x / scale)
|
|
42
|
+
determinant = a - b
|
|
43
|
+
if determinant.abs <= 8 * Float::EPSILON * [a.abs, b.abs].max
|
|
44
|
+
Error.("Ellipse transform is singular at floating-point precision")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
determinant.positive? ? 1 : -1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
51
|
+
def recover(u, v, center)
|
|
52
|
+
scale = [u.x.abs, u.y.abs, v.x.abs, v.y.abs].max
|
|
53
|
+
Error.("Ellipse transform collapses its radii") if scale.zero?
|
|
54
|
+
|
|
55
|
+
ux, uy, vx, vy = [u.x, u.y, v.x, v.y].map { it / scale }
|
|
56
|
+
a, b, c = (ux * ux) + (vx * vx), (ux * uy) + (vx * vy), (uy * uy) + (vy * vy)
|
|
57
|
+
major = ::Math.sqrt((a + c + ::Math.hypot(a - c, 2 * b)) / 2)
|
|
58
|
+
# The determinant avoids cancellation in the smaller eigenvalue.
|
|
59
|
+
minor = ((ux * vy) - (uy * vx)).abs / major
|
|
60
|
+
rotation = F.to_degrees(::Math.atan2(2 * b, a - c)) / 2
|
|
61
|
+
Ellipse[major * scale, minor * scale, position: center, rotation:]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def similarity?(u, v)
|
|
65
|
+
((u.x * v.x) + (u.y * v.y)).zero? && ::Math.hypot(u.x, u.y) == ::Math.hypot(v.x, v.y)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private_constant :Affine
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Geometry
|
|
5
|
+
class Ellipse
|
|
6
|
+
# Bounded adaptive Simpson integration of elliptical arc speed.
|
|
7
|
+
# Radii are scaled before integration to keep the integrand finite.
|
|
8
|
+
# @api private
|
|
9
|
+
class Length
|
|
10
|
+
ABSOLUTE_ERROR = 1e-9
|
|
11
|
+
EVALUATIONS = 65_536
|
|
12
|
+
RELATIVE_ERROR = 1e-10
|
|
13
|
+
|
|
14
|
+
def initialize(rx, ry)
|
|
15
|
+
@scale = [rx, ry].max
|
|
16
|
+
@rx, @ry = rx / @scale, ry / @scale
|
|
17
|
+
@evaluations = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
21
|
+
def integrate(starting_angle, extent)
|
|
22
|
+
return 0.0 if extent.zero?
|
|
23
|
+
|
|
24
|
+
@starting = F.to_radians((extent.negative? ? starting_angle + extent : starting_angle) % 360)
|
|
25
|
+
span = F.to_radians(extent.abs)
|
|
26
|
+
cuts = [0.0, *(1..8).map { (it * ::Math::PI / 2) - @starting }.select { it.positive? && it < span }, span]
|
|
27
|
+
# Quarter-turn chords give a lower bound for relative error, including highly eccentric ellipses.
|
|
28
|
+
lower_bound = cuts.each_cons(2).sum { |left, right| chord(left, right) }
|
|
29
|
+
tolerance = (ABSOLUTE_ERROR / @scale) + (RELATIVE_ERROR * lower_bound)
|
|
30
|
+
integral = cuts.each_cons(2).sum do |left, right|
|
|
31
|
+
integrate_interval(left, right, tolerance * ((right - left) / span))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Real[:length, integral * @scale]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def chord(left, right)
|
|
40
|
+
left, right = @starting + left, @starting + right
|
|
41
|
+
::Math.hypot(@rx * (::Math.cos(right) - ::Math.cos(left)), @ry * (::Math.sin(right) - ::Math.sin(left)))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
|
45
|
+
def integrate_interval(left, right, tolerance)
|
|
46
|
+
fa, fm, fb = [left, (left + right) / 2, right].map { speed(it) }
|
|
47
|
+
stack = [[left, right, fa, fm, fb, simpson(left, right, fa, fm, fb), tolerance]]
|
|
48
|
+
integral = 0.0
|
|
49
|
+
until stack.empty?
|
|
50
|
+
a, b, fa, fm, fb, whole, error = stack.pop
|
|
51
|
+
middle = (a + b) / 2
|
|
52
|
+
fl, fr = speed((a + middle) / 2), speed((middle + b) / 2)
|
|
53
|
+
lower, upper = simpson(a, middle, fa, fl, fm), simpson(middle, b, fm, fr, fb)
|
|
54
|
+
correction = lower + upper - whole
|
|
55
|
+
if correction.abs <= 15 * error
|
|
56
|
+
integral += lower + upper + (correction / 15)
|
|
57
|
+
else
|
|
58
|
+
Error.("Ellipse length cannot converge at floating-point precision") if middle == a || middle == b
|
|
59
|
+
stack << [middle, b, fm, fr, fb, upper, error / 2]
|
|
60
|
+
stack << [a, middle, fa, fl, fm, lower, error / 2]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
integral
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def simpson(left, right, first, middle, last) = (right - left) * (first + (4 * middle) + last) / 6
|
|
68
|
+
|
|
69
|
+
def speed(offset)
|
|
70
|
+
@evaluations += 1
|
|
71
|
+
Error.("Ellipse length exceeded #{EVALUATIONS} evaluations") if @evaluations > EVALUATIONS
|
|
72
|
+
angle = @starting + offset
|
|
73
|
+
::Math.hypot(@rx * ::Math.sin(angle), @ry * ::Math.cos(angle))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private_constant :Length
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|