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,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Geometry
5
+ # rubocop:disable Metrics/ClassLength
6
+
7
+ # Immutable ellipse with positive radii, a center, and clockwise axis rotation.
8
+ # Parameter angles belong to the local ellipse axes, not to polar directions from the center.
9
+ # @example Select a finite boundary and inspect its endpoints
10
+ # ellipse = Sevgi::Geometry::Ellipse[4, 2, position: [10, 20]]
11
+ # ellipse.point(90).deconstruct # => [10.0, 22.0]
12
+ # ellipse.arc(starting_angle: 0, extent: 90).ending == ellipse.point(90)
13
+ class Ellipse < Element::Arced
14
+ # Builds an ellipse from local radii and its position.
15
+ # @param rx [Numeric] positive local x radius
16
+ # @param ry [Numeric] positive local y radius
17
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] ellipse center
18
+ # @param rotation [Numeric] clockwise rotation in degrees
19
+ # @return [Sevgi::Geometry::Ellipse]
20
+ # @raise [Sevgi::Geometry::Error] when inputs are not finite real values or a radius is not positive
21
+ def self.[](rx, ry, position: Origin, rotation: 0) = new(rx, ry, position:, rotation:)
22
+
23
+ def self.close? = true
24
+ private_class_method :close?
25
+
26
+ # @return [Sevgi::Geometry::Point] ellipse center
27
+ attr_reader :position
28
+ # @return [Float] clockwise rotation of the local axes in degrees
29
+ attr_reader :rotation
30
+ # @return [Float] positive local x radius
31
+ attr_reader :rx
32
+ # @return [Float] positive local y radius
33
+ attr_reader :ry
34
+
35
+ # Creates an ellipse. Use the bracket constructor.
36
+ # @param rx [Numeric] positive local x radius
37
+ # @param ry [Numeric] positive local y radius
38
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] center
39
+ # @param rotation [Numeric] clockwise rotation in degrees
40
+ # @return [void]
41
+ # @raise [Sevgi::Geometry::Error] when an input is invalid
42
+ def initialize(rx, ry, position:, rotation:)
43
+ super()
44
+ @rx, @ry = [[:rx, rx], [:ry, ry]].map do |field, value|
45
+ value = Real[field, value]
46
+ Error.("Ellipse #{field} must be positive") unless value.positive?
47
+ value
48
+ end
49
+
50
+ @position = Tuple[Point, position]
51
+ @rotation = Real[:rotation, rotation]
52
+ end
53
+
54
+ # Rebuilds an ellipse from rounded canonical fields.
55
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
56
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
57
+ # @raise [Sevgi::Geometry::Error] when rounding makes a radius zero
58
+ # @raise [Sevgi::ArgumentError] when precision is invalid
59
+ def approx(precision = nil)
60
+ rebuild(
61
+ F.approx(rx, precision),
62
+ F.approx(ry, precision),
63
+ position.approx(precision),
64
+ F.approx(rotation, precision)
65
+ )
66
+ end
67
+
68
+ # Selects a finite, directed arc on this ellipse.
69
+ # @param starting_angle [Numeric] local starting parameter angle in degrees
70
+ # @param extent [Numeric] signed angular extent strictly between -360 and 360 degrees
71
+ # @return [Sevgi::Geometry::Arc]
72
+ # @raise [Sevgi::Geometry::Error] when angles are invalid
73
+ def arc(extent:, starting_angle: 0)
74
+ parent = is_a?(Circle) ? Ellipse[rx, ry, position:] : self
75
+ Arc.send(:new, parent, starting_angle:, extent:)
76
+ end
77
+
78
+ # rubocop:disable Metrics/AbcSize
79
+
80
+ # Returns the axis-aligned bounds without display rounding.
81
+ # @return [Sevgi::Geometry::Rect]
82
+ def box
83
+ cosine, sine = F.cos(rotation), F.sin(rotation)
84
+ dx = ::Math.hypot(rx * cosine, ry * sine)
85
+ dy = ::Math.hypot(rx * sine, ry * cosine)
86
+ Rect.from_corners([position.x - dx, position.y - dy], [position.x + dx, position.y + dy])
87
+ end
88
+
89
+ # rubocop:enable Metrics/AbcSize
90
+
91
+ # Reports whether the radii are exactly equal.
92
+ # @return [Boolean]
93
+ def circular? = rx == ry
94
+
95
+ # rubocop:disable Metrics/AbcSize
96
+
97
+ # Draws an SVG ellipse using original geometric values.
98
+ # @param node [Object] graphics node receiving the element
99
+ # @param attributes [Hash] SVG attributes, including an optional outer transform
100
+ # @return [Object] graphics command result
101
+ def draw(node, **attributes)
102
+ unless rotation.zero?
103
+ transform = "rotate(#{rotation} #{position.x} #{position.y})"
104
+ attributes = attributes.merge(transform: [attributes[:transform], transform].compact.join(" "))
105
+ end
106
+
107
+ node.ellipse(cx: position.x, cy: position.y, rx:, ry:, **attributes)
108
+ end
109
+
110
+ # rubocop:enable Metrics/AbcSize
111
+
112
+ # Reports whether the ellipse has zero angular extent. A complete ellipse is never empty.
113
+ # @return [Boolean]
114
+ def empty? = false
115
+
116
+ # Returns the complete quadratic carrier.
117
+ # @return [Sevgi::Geometry::Equation::Quadratic]
118
+ def equation = @equation ||= Equation.quadratic(*coefficients, origin: position)
119
+
120
+ # Returns the immutable collection containing the complete carrier.
121
+ # @return [Array<Sevgi::Geometry::Equation::Quadratic>]
122
+ def equations = @equations ||= [equation].freeze
123
+
124
+ # Reports whether a point is inside or on the boundary.
125
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
126
+ # @return [Boolean]
127
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
128
+ def inside?(point)
129
+ point = Tuple[Point, point]
130
+ local = local(point)
131
+ ::Math.hypot(local.x / rx, local.y / ry) < 1.0 || on?(point)
132
+ end
133
+
134
+ # Returns the perimeter with thread-independent numerical accuracy.
135
+ # @return [Float]
136
+ # @raise [Sevgi::Geometry::Error] when length is not finite or integration cannot meet its error target
137
+ def length = @length ||= arc_length(0, 360)
138
+
139
+ # Reports whether a point matches its radial boundary reference at the current coordinate precision.
140
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
141
+ # @return [Boolean]
142
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
143
+ def on?(point)
144
+ point = Tuple[Point, point]
145
+ point != position && point.eq?(self.point(parameter(point)))
146
+ end
147
+
148
+ # Returns the closed boundary length.
149
+ # @return [Float]
150
+ def perimeter = length
151
+
152
+ # Evaluates the boundary at a local parameter angle.
153
+ # @param angle [Numeric] clockwise local angle in degrees
154
+ # @return [Sevgi::Geometry::Point]
155
+ # @raise [Sevgi::Geometry::Error] when angle or the resulting coordinates are not finite
156
+ def point(angle)
157
+ angle = Real[:angle, angle] % 360.0
158
+ Point[rx * F.cos(angle), ry * F.sin(angle)].rotate(rotation).translate(position.x, position.y)
159
+ end
160
+
161
+ # Returns a reflected copy, preserving Circle where applicable.
162
+ # @param x [Boolean] reflect across the x-axis
163
+ # @param y [Boolean] reflect across the y-axis
164
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
165
+ # @raise [Sevgi::Geometry::Error] when a flag is not Boolean
166
+ def reflect(x: true, y: true) = affine(:reflect, x:, y:).first
167
+
168
+ # Rotates the center around the origin and the ellipse axes by the same angle.
169
+ # @param angle [Numeric] clockwise angle in degrees
170
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
171
+ # @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
172
+ def rotate(angle)
173
+ angle = Real[:angle, angle]
174
+ rebuild(rx, ry, position.rotate(angle), rotation + angle)
175
+ end
176
+
177
+ # rubocop:disable Metrics/AbcSize
178
+
179
+ # Scales the ellipse from the origin. Unequal factors can widen Circle to Ellipse.
180
+ # @param sx [Numeric] x scale factor
181
+ # @param sy [Numeric, Sevgi::Undefined] y factor, defaulting to sx
182
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
183
+ # @raise [Sevgi::Geometry::Error] when the transform is singular or the resulting geometry is invalid
184
+ def scale(sx, sy = Undefined)
185
+ sx, sy = Real[:sx, sx], Real[:sy, Undefined.default(sy, sx)]
186
+ return affine(:scale, sx, sy).first unless sx == sy
187
+
188
+ rebuild(rx * sx.abs, ry * sy.abs, position.scale(sx, sy), rotation + (sx.negative? ? 180 : 0))
189
+ end
190
+
191
+ # rubocop:enable Metrics/AbcSize
192
+
193
+ # Skews the ellipse from the origin.
194
+ # @param ax [Numeric] x-axis skew angle in degrees
195
+ # @param ay [Numeric, Sevgi::Undefined] y-axis angle, defaulting to ax
196
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
197
+ # @raise [Sevgi::Geometry::Error] when the transform is singular or the resulting geometry is invalid
198
+ def skew(ax, ay = Undefined) = affine(:skew, ax, ay).first
199
+
200
+ # Skews the ellipse along x.
201
+ # @param angle [Numeric] skew angle in degrees
202
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
203
+ # @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
204
+ def skew_x(angle) = affine(:skew_x, angle).first
205
+
206
+ # Skews the ellipse along y.
207
+ # @param angle [Numeric] skew angle in degrees
208
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
209
+ # @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
210
+ def skew_y(angle) = affine(:skew_y, angle).first
211
+
212
+ # Returns a translated copy.
213
+ # @param dx [Numeric] x offset
214
+ # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
215
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
216
+ # @raise [Sevgi::Geometry::Error] when offsets or the resulting center are invalid
217
+ def translate(dx, dy = Undefined) = rebuild(rx, ry, position.translate(dx, dy), rotation)
218
+
219
+ alias center position
220
+
221
+ private
222
+
223
+ def affine(...) = Affine.new(self).transform(...)
224
+
225
+ def arc_length(starting_angle, extent)
226
+ return Real[:length, rx * F.to_radians(extent.abs)] if circular?
227
+
228
+ Length.new(rx, ry).integrate(starting_angle, extent)
229
+ end
230
+
231
+ # rubocop:disable-next Metrics/AbcSize
232
+ def coefficients
233
+ cosine, sine = F.cos(rotation), F.sin(rotation)
234
+ a = ((cosine / rx) ** 2) + ((sine / ry) ** 2)
235
+ b = 2 * cosine * sine * (((1.0 / rx) ** 2) - ((1.0 / ry) ** 2))
236
+ c = ((sine / rx) ** 2) + ((cosine / ry) ** 2)
237
+ [a, b, c, 0, 0, -1]
238
+ end
239
+
240
+ # rubocop:disable-next Metrics/AbcSize
241
+ def extrema
242
+ cosine, sine = F.cos(rotation), F.sin(rotation)
243
+ x = F.atan2(-ry * sine, rx * cosine)
244
+ y = F.atan2(ry * cosine, rx * sine)
245
+ [x, x + 180, y, y + 180]
246
+ end
247
+
248
+ def local(point) = point.translate(-position.x, -position.y).rotate(-rotation)
249
+
250
+ def parameter(point)
251
+ point = local(point)
252
+ F.to_degrees(::Math.atan2(point.y / ry, point.x / rx))
253
+ end
254
+
255
+ def rebuild(rx, ry, position, rotation)
256
+ return Circle[rx, position:] if is_a?(Circle) && rx == ry
257
+
258
+ Ellipse[rx, ry, position:, rotation:]
259
+ end
260
+
261
+ def state = [position, rx, ry, rotation]
262
+
263
+ require_relative "ellipse/affine"
264
+ require_relative "ellipse/length"
265
+ end
266
+
267
+ # rubocop:enable Metrics/ClassLength
268
+ end
269
+ end
@@ -10,7 +10,7 @@ module Sevgi
10
10
  # Finite, directed line between two endpoints.
11
11
  #
12
12
  # Direction affects {#left?}, {#right?}, and the sign of {#shift}. Use
13
- # {#equation} when the corresponding infinite line is required; {#over?}
13
+ # {#equation} when the corresponding infinite line is required. {#over?}
14
14
  # deliberately tests only the finite extent between the endpoints.
15
15
  # @example Query sides of a directed line in screen coordinates
16
16
  # line = Sevgi::Geometry::Line.([0, 0], [10, 0])
@@ -27,6 +27,15 @@ module Sevgi
27
27
  # @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
28
28
  # @return [Sevgi::Geometry::Line]
29
29
  # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
30
+ # @!method starting
31
+ # Returns the first point in the directed path.
32
+ # @return [Sevgi::Geometry::Point]
33
+ # @!method ending
34
+ # Returns the last point in the directed path.
35
+ # @return [Sevgi::Geometry::Point]
36
+ # @!method reverse
37
+ # Returns the same finite trace with opposite traversal.
38
+ # @return [Sevgi::Geometry::Line]
30
39
  # @!attribute [r] A
31
40
  # @return [Sevgi::Geometry::Point] starting point
32
41
  # @!attribute [r] B
@@ -69,10 +78,6 @@ module Sevgi
69
78
  # @return [Float]
70
79
  def angle = head.angle
71
80
 
72
- # Returns the ending point.
73
- # @return [Sevgi::Geometry::Point]
74
- def ending = points.last
75
-
76
81
  # Reports whether a point is left of the directed line from {#starting} to {#ending} in screen coordinates.
77
82
  # Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
78
83
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
@@ -87,10 +92,6 @@ module Sevgi
87
92
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
88
93
  def right?(point) = F.gt?(side(point), 0.0)
89
94
 
90
- # Returns the starting point.
91
- # @return [Sevgi::Geometry::Point]
92
- def starting = points.first
93
-
94
95
  # Draws the line into a graphics node.
95
96
  # @param node [Object] graphics node receiving the drawing command
96
97
  # @return [Object] graphics node command result
@@ -109,7 +110,7 @@ module Sevgi
109
110
  end
110
111
 
111
112
  # Returns a parallel line shifted by a signed perpendicular offset.
112
- # Positive distance moves to the directed line's left in screen coordinates; reversing the endpoints reverses the
113
+ # Positive distance moves to the directed line's left in screen coordinates. Reversing the endpoints reverses the
113
114
  # shift direction.
114
115
  # @param distance [Numeric] signed perpendicular offset
115
116
  # @return [Sevgi::Geometry::Line]
@@ -121,12 +122,11 @@ module Sevgi
121
122
 
122
123
  private
123
124
 
124
- def cross(ax, ay, bx, by) = (ax * by) - (ay * bx)
125
125
  def delta(from, to) = [to.x - from.x, to.y - from.y]
126
126
 
127
127
  def side(point)
128
128
  point = Tuple[Point, point]
129
- cross(*delta(starting, ending), *delta(starting, point))
129
+ Cross[*delta(starting, ending), *delta(starting, point)]
130
130
  end
131
131
 
132
132
  def within_range?(point)
@@ -8,7 +8,7 @@ module Sevgi
8
8
  private_constant :ParallelogramBase
9
9
 
10
10
  # Closed four-sided element whose opposite sides are equal and parallel. Every construction path rejects
11
- # degenerate or unrelated side pairs; affine operations preserve the class while that invariant holds.
11
+ # degenerate or unrelated side pairs. Affine operations preserve the class while that invariant holds.
12
12
  # @!method self.call(*points)
13
13
  # Builds a parallelogram from four boundary points.
14
14
  # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] four boundary points
@@ -56,7 +56,7 @@ module Sevgi
56
56
  # shape.box.width # => 6.0
57
57
  # shape.box.height # => 3.0
58
58
  class Parallelogram < ParallelogramBase
59
- # Builds a parallelogram from adjacent base and side segments. Both segments originate at `position`; `base`
59
+ # Builds a parallelogram from adjacent base and side segments. Both segments originate at `position`. `base`
60
60
  # defines AB and `side` defines AD, regardless of their angles.
61
61
  # @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
62
62
  # @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
@@ -69,7 +69,7 @@ module Sevgi
69
69
  new_by_segments(base, side.reverse, base.reverse, side, position:)
70
70
  end
71
71
 
72
- # Builds a parallelogram from a base and bounding-height constraint. The constraint length is the target height;
72
+ # Builds a parallelogram from a base and bounding-height constraint. The constraint length is the target height.
73
73
  # its signed angle is retained as the direction of the derived side while the component magnitude determines that
74
74
  # side's non-negative length.
75
75
  # @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
@@ -96,7 +96,7 @@ module Sevgi
96
96
  self[base, Segment[height / sine.abs, angle], position:]
97
97
  end
98
98
 
99
- # Builds a parallelogram from a side and bounding-width constraint. The constraint length is the target width; its
99
+ # Builds a parallelogram from a side and bounding-width constraint. The constraint length is the target width. Its
100
100
  # signed angle is retained as the direction of the derived base while the component magnitude determines that
101
101
  # base's non-negative length.
102
102
  # @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
@@ -127,13 +127,11 @@ module Sevgi
127
127
 
128
128
  def validate_geometry!
129
129
  a, b, c, d = segments
130
- valid = opposite?(a, c) && opposite?(b, d) && !F.zero?(cross(a, b))
130
+ valid = opposite?(a, c) && opposite?(b, d) && !F.zero?(Cross[a.x, a.y, b.x, b.y])
131
131
 
132
132
  Error.("Parallelogram sides must be non-degenerate opposite pairs") unless valid
133
133
  end
134
134
 
135
- def cross(a, b) = (a.x * b.y) - (a.y * b.x)
136
-
137
135
  def opposite?(a, b) = F.zero?(a.x + b.x) && F.zero?(a.y + b.y)
138
136
  end
139
137
  end
@@ -30,6 +30,15 @@ module Sevgi
30
30
  # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] ordered points
31
31
  # @return [Sevgi::Geometry::Polyline]
32
32
  # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
33
+ # @!method starting
34
+ # Returns the first point in the directed path.
35
+ # @return [Sevgi::Geometry::Point]
36
+ # @!method ending
37
+ # Returns the last point in the directed path.
38
+ # @return [Sevgi::Geometry::Point]
39
+ # @!method reverse
40
+ # Returns the same trace with opposite traversal.
41
+ # @return [Sevgi::Geometry::Polyline]
33
42
  # @example Pair mathematical notation with English conveniences
34
43
  # Sevgi::Geometry::Polyline[[2, 0], [1, 90]] == Sevgi::Geometry::Polyline.from_segments([2, 0], [1, 90])
35
44
  # Sevgi::Geometry::Polyline.([0, 0], [2, 0]) == Sevgi::Geometry::Polyline.from_points([0, 0], [2, 0])
@@ -161,6 +161,10 @@ module Sevgi
161
161
 
162
162
  private :draw!
163
163
 
164
+ # Returns the rectangle center.
165
+ # @return [Sevgi::Geometry::Point]
166
+ def center = Point.midpoint(top_left, bottom_right)
167
+
164
168
  # Returns rectangle height.
165
169
  # @return [Float]
166
170
  def height = @height ||= segments[1].length
@@ -220,7 +224,7 @@ module Sevgi
220
224
  end
221
225
  end
222
226
 
223
- # Rectangle with equal width and height. Use {#width} or {#height} for its side length; inherited
227
+ # Rectangle with equal width and height. Use {#width} or {#height} for its side length. Inherited
224
228
  # {Element::Lined#length} returns the complete path length.
225
229
  # @example Construct the same square from opposite corners
226
230
  # Sevgi::Geometry::Square.([0, 0], [5, 5]) == Sevgi::Geometry::Square.from_corners([0, 0], [5, 5])
@@ -8,7 +8,7 @@ module Sevgi
8
8
  private_constant :TriangleBase
9
9
 
10
10
  # Closed three-sided element built from non-collinear segments or points. Every construction path rejects
11
- # degenerate triangles; affine operations retain Triangle when the transformed points remain non-degenerate.
11
+ # degenerate triangles. Affine operations retain Triangle when the transformed points remain non-degenerate.
12
12
  # @!method self.call(*points)
13
13
  # Builds a triangle from three boundary points.
14
14
  # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] three boundary points
@@ -53,7 +53,7 @@ module Sevgi
53
53
  # Builds a triangle from two adjacent segments.
54
54
  #
55
55
  # The closing segment is the direct vector from the end of `segment_b`
56
- # back to `position`. Segment order controls orientation; reversing the
56
+ # back to `position`. Segment order controls orientation. Reversing the
57
57
  # inputs returns the corresponding opposite orientation. Zero-length or
58
58
  # collinear inputs are rejected using the current numeric precision.
59
59
  # @param segment_a [Sevgi::Geometry::Segment, Array<Numeric>] first segment
@@ -75,14 +75,11 @@ module Sevgi
75
75
  Segment.(b.ending(a.ending(Origin)), Origin)
76
76
  end
77
77
 
78
- def cross(a, b) = (a.x * b.y) - (a.y * b.x)
79
-
80
78
  def validate!(a, b)
81
- if F.zero?(a.length) ||
82
- F.zero?(b.length) ||
83
- F.zero?(cross(a, b))
84
- Error.("Triangle segments must form a non-degenerate triangle")
85
- end
79
+ middle = a.ending(Origin)
80
+ return unless F.zero?(a.length) || F.zero?(b.length) || Point.collinear?(Origin, middle, b.ending(middle))
81
+
82
+ Error.("Triangle segments must form a non-degenerate triangle")
86
83
  end
87
84
  end
88
85
 
@@ -3,26 +3,108 @@
3
3
  module Sevgi
4
4
  module Geometry
5
5
  class Equation
6
- # Base class reserved for future quadratic equation support.
7
- # @api private
6
+ # Implicit second-degree carrier with an optional local coordinate origin.
7
+ # The origin avoids expanding large ellipse centers into coefficients that lose the radius through cancellation.
8
+ # @example Intersect a unit circle carrier with a horizontal line
9
+ # equation = Sevgi::Geometry::Equation.quadratic(1, 0, 1, 0, 0, -1)
10
+ # equation.y(0) # => [-1.0, 1.0]
8
11
  class Quadratic < Equation
9
- end
12
+ public_class_method :new
10
13
 
11
- private_constant :Quadratic
12
- end
14
+ # @return [Array<Float>] immutable coefficients in a, b, c, d, e, f order
15
+ attr_reader :coefficients
16
+ # @return [Sevgi::Geometry::Point] local coordinate origin
17
+ attr_reader :origin
18
+
19
+ # Creates a quadratic equation in local coordinates.
20
+ # @param coefficients [Array<Numeric>] six finite coefficients, with a nonzero second-degree term
21
+ # @param origin [Sevgi::Geometry::Point, Array<Numeric>] local coordinate origin
22
+ # @return [void]
23
+ # @raise [Sevgi::Geometry::Error] when coefficients or origin are invalid
24
+ def initialize(*coefficients, origin: Origin)
25
+ super()
26
+ Error.("Quadratic equation requires six coefficients") unless coefficients.size == 6
27
+ @coefficients = coefficients.each_with_index.map { |value, i| Real[("a".."f").to_a[i], value] }.freeze
28
+ Error.("Quadratic equation requires a second-degree term") if @coefficients.first(3).all?(&:zero?)
29
+ @origin = Tuple[Point, origin]
30
+ end
31
+
32
+ # Reports exact equality of coefficients and the local origin.
33
+ # @param other [Object] comparison target
34
+ # @return [Boolean]
35
+ def eql?(other) = other.instance_of?(self.class) && coefficients == other.coefficients && origin == other.origin
36
+
37
+ # Returns a hash compatible with strict equality.
38
+ # @return [Integer]
39
+ def hash = [self.class, coefficients, origin].hash
40
+
41
+ # rubocop:disable Metrics/AbcSize
42
+
43
+ # Returns the finite y roots at a world x coordinate, in ascending order.
44
+ # @param x [Numeric] finite world x coordinate
45
+ # @return [Array<Float>] zero, one, or two y coordinates
46
+ # @raise [Sevgi::Geometry::Error] when x or a result is not finite, or y is indeterminate
47
+ def y(x)
48
+ x = Real[:x, x] - origin.x
49
+ a, b, c, d, e, f = coefficients
50
+ roots = roots(c, sum(b * x, e), sum(a * x * x, d * x, f))
51
+ Error.("y is indeterminate for this quadratic equation") unless roots
52
+ roots.map { Real[:y, it + origin.y] }
53
+ end
54
+
55
+ # rubocop:enable Metrics/AbcSize
13
56
 
14
- # Reserved circle element support.
15
- # @api private
16
- class Circle
17
- # Returns the circle equation.
18
- # @abstract Circle equation support is not implemented yet.
19
- # @return [Sevgi::Geometry::Equation::Quadratic]
20
- # @raise [Sevgi::PanicError] until circle equation support is implemented
21
- def equation
22
- PanicError.("#{self.class}#equation must be implemented")
57
+ # @return [Boolean] exact equality of coefficients and origin
58
+ alias == eql?
59
+
60
+ private
61
+
62
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
63
+ def intersect_linear(line)
64
+ if line.is_a?(Linear::Vertical)
65
+ x = line.x - origin.x
66
+ a, b, c, d, e, f = coefficients
67
+ ys = roots(c, sum(b * x, e), sum(a * x * x, d * x, f))
68
+ return Array(ys).map { Point[line.x, it + origin.y] }
69
+ end
70
+
71
+ a, b, c, d, e, f = coefficients
72
+ slope, intercept = line.slope, line.y(origin.x) - origin.y
73
+ xs = roots(
74
+ sum(a, b * slope, c * slope * slope),
75
+ sum(b * intercept, 2 * c * slope * intercept, d, e * slope),
76
+ sum(c * intercept * intercept, e * intercept, f)
77
+ )
78
+ Array(xs).map { |x| Point[x + origin.x, (slope * x) + intercept + origin.y] }
79
+ end
80
+
81
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
82
+ def roots(a, b, c)
83
+ scale = [a.abs, b.abs, c.abs].max
84
+ return nil if scale.zero?
85
+ Error.("Quadratic intersection coefficients are not finite") unless scale.finite?
86
+ a, b, c = [a, b, c].map { it / scale }
87
+ return b.zero? ? [] : [-c / b] if a.zero?
88
+
89
+ square, product = b * b, 4 * a * c
90
+ discriminant = square - product
91
+ # Only arithmetic cancellation at a tangent can merge roots; display precision does not classify them.
92
+ discriminant = 0.0 if discriminant.abs <= 8 * Float::EPSILON * (square.abs + product.abs)
93
+ return [] if discriminant.negative?
94
+ return [-b / (2 * a)] if discriminant.zero?
95
+
96
+ root = ::Math.sqrt(discriminant)
97
+ q = -0.5 * (b + (b.negative? ? -root : root))
98
+ [q / a, c / q].sort
99
+ end
100
+
101
+ # Substitution can cancel before the discriminant is formed, notably at axis-aligned tangents.
102
+ def sum(*terms)
103
+ value = terms.sum
104
+ Error.("Quadratic intersection coefficients are not finite") unless value.finite?
105
+ value.abs <= 8 * Float::EPSILON * terms.sum(&:abs) ? 0.0 : value
106
+ end
23
107
  end
24
108
  end
25
-
26
- private_constant :Circle
27
109
  end
28
110
  end