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.
@@ -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