sevgi-geometry 0.95.0 → 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.
@@ -3,10 +3,61 @@
3
3
  module Sevgi
4
4
  module Geometry
5
5
  class Equation
6
- # Base class for linear equations.
6
+ # Base class for unoriented linear equations.
7
+ #
8
+ # An equation has no ordered endpoints, so it does not define intrinsic left and right sides. Signed shifts use a
9
+ # canonical direction only to select a normal: increasing x for non-vertical equations and increasing y for
10
+ # vertical equations. Positive distance moves to screen-left of that canonical direction.
7
11
  class Linear < Equation
12
+ def shift_values(distance, dx, dy)
13
+ [[:distance, distance], [:dx, dx], [:dy, dy]].map do |field, value|
14
+ value.nil? ? 0.0 : Real[field, value]
15
+ end
16
+ end
17
+
18
+ private :shift_values
19
+
20
+ # Shared behavior for equations that map x coordinates to y coordinates.
21
+ # @api private
22
+ module Nonvertical
23
+ # Reports strict equality for two nonvertical equations.
24
+ # @param equation [Sevgi::Geometry::Equation::Linear] equation to compare
25
+ # @param other [Object] comparison target
26
+ # @return [Boolean]
27
+ def self.equal?(equation, other)
28
+ equation.class == other.class && [equation.slope, equation.intercept] == [other.slope, other.intercept]
29
+ end
30
+
31
+ # Returns a strict-equality hash for a nonvertical equation.
32
+ # @param equation [Sevgi::Geometry::Equation::Linear] equation to hash
33
+ # @return [Integer]
34
+ def self.hash(equation) = [equation.class, equation.slope, equation.intercept].hash
35
+
36
+ # Reports whether a point is on a nonvertical equation.
37
+ # @param equation [Sevgi::Geometry::Equation::Linear] equation to test
38
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
39
+ # @return [Boolean]
40
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
41
+ def self.on?(equation, point)
42
+ point = Tuple[Point, point]
43
+
44
+ F.eq?(point.y, y(equation, point.x))
45
+ end
46
+
47
+ # Evaluates a nonvertical equation at an x coordinate.
48
+ # @param equation [Sevgi::Geometry::Equation::Linear] equation to evaluate
49
+ # @param x [Numeric] x coordinate
50
+ # @return [Float]
51
+ # @raise [Sevgi::Geometry::Error] when x is not a finite real number
52
+ def self.y(equation, x) = (equation.slope * Real[:x, x]) + equation.intercept
53
+ end
54
+
55
+ private_constant :Nonvertical
56
+
8
57
  # Non-axis-aligned linear equation in `y = slope * x + intercept` form.
9
58
  class Diagonal < Linear
59
+ public_class_method :new
60
+
10
61
  # Returns the line slope.
11
62
  # @return [Float]
12
63
  attr_reader :slope
@@ -19,12 +70,13 @@ module Sevgi
19
70
  # @param slope [Numeric] line slope
20
71
  # @param intercept [Numeric] y-intercept
21
72
  # @return [void]
22
- # @raise [Sevgi::Geometry::Error] when a coefficient is not a finite Numeric
73
+ # @raise [Sevgi::Geometry::Error] when a coefficient is not finite Numeric or slope is zero
23
74
  def initialize(slope:, intercept:)
24
75
  super()
25
76
 
26
77
  @slope = Real[:slope, slope]
27
78
  @intercept = Real[:intercept, intercept]
79
+ Error.("A diagonal equation requires a non-zero slope") if @slope.zero?
28
80
  end
29
81
 
30
82
  # Returns an equation rounded to precision.
@@ -37,55 +89,30 @@ module Sevgi
37
89
  # Reports strict equation equality.
38
90
  # @param other [Object] object to compare
39
91
  # @return [Boolean]
40
- def eql?(other) = self.class == other.class && [slope, intercept] == [other.slope, other.intercept]
92
+ def eql?(other) = Nonvertical.equal?(self, other)
41
93
 
42
94
  # Returns a hash compatible with strict equality.
43
95
  # @return [Integer]
44
- def hash = [self.class, slope, intercept].hash
45
-
46
- # Reports whether a point is on the left side of the line in screen coordinates.
47
- # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
48
- # @return [Boolean]
49
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
50
- def left?(point)
51
- point = Tuple[Point, point]
52
-
53
- F.gt?(point.y, y(point.x))
54
- end
96
+ def hash = Nonvertical.hash(self)
55
97
 
56
98
  # Reports whether a point is on the line.
57
99
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
58
100
  # @return [Boolean]
59
101
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
60
- def on?(point)
61
- point = Tuple[Point, point]
62
-
63
- F.eq?(point.y, y(point.x))
64
- end
65
-
66
- # Reports whether a point is on the right side of the line in screen coordinates.
67
- # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
68
- # @return [Boolean]
69
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
70
- def right?(point)
71
- point = Tuple[Point, point]
72
-
73
- F.lt?(point.y, y(point.x))
74
- end
102
+ def on?(point) = Nonvertical.on?(self, point)
75
103
 
76
104
  # Returns a parallel equation shifted by a signed perpendicular offset.
105
+ # Positive distance moves to screen-left of the equation's canonical increasing-x direction.
77
106
  # @param distance [Numeric, nil] signed perpendicular offset
78
107
  # @param dx [Numeric, nil] explicit x translation
79
108
  # @param dy [Numeric, nil] explicit y translation
80
109
  # @return [Sevgi::Geometry::Equation::Linear::Diagonal]
110
+ # @raise [Sevgi::Geometry::Error] when an operand is not a finite real number
81
111
  def shift(distance = nil, dx: nil, dy: nil)
82
- dx ||= 0.0
83
- dy ||= 0.0
84
-
85
- if distance
86
- dx += distance * F.sin(angle = F.atan(slope))
87
- dy -= distance * F.cos(angle)
88
- end
112
+ distance, dx, dy = shift_values(distance, dx, dy)
113
+ angle = F.atan(slope)
114
+ dx += distance * F.sin(angle)
115
+ dy -= distance * F.cos(angle)
89
116
 
90
117
  Diagonal.new(slope:, intercept: intercept - (slope * dx) + dy)
91
118
  end
@@ -104,29 +131,61 @@ module Sevgi
104
131
  # Evaluates x for a y coordinate.
105
132
  # @param y [Numeric] y coordinate
106
133
  # @return [Float]
107
- def x(y) = (y - intercept) / slope
134
+ # @raise [Sevgi::Geometry::Error] when y is not a finite real number
135
+ def x(y) = (Real[:y, y] - intercept) / slope
108
136
 
109
137
  # Evaluates y for an x coordinate.
110
138
  # @param x [Numeric] x coordinate
111
139
  # @return [Float]
112
- def y(x) = (slope * x) + intercept
140
+ # @raise [Sevgi::Geometry::Error] when x is not a finite real number
141
+ def y(x) = Nonvertical.y(self, x)
113
142
 
114
143
  alias == eql?
115
144
  end
116
145
 
117
146
  # Horizontal linear equation in `y = c` form.
118
- class Horizontal < Diagonal
147
+ class Horizontal < Linear
148
+ public_class_method :new
149
+
150
+ # Returns the zero line slope.
151
+ # @return [Float]
152
+ attr_reader :slope
153
+
154
+ # Returns the y coordinate.
155
+ # @return [Float]
156
+ attr_reader :intercept
157
+
119
158
  # Creates a horizontal equation.
120
159
  # @param c [Numeric] y coordinate
121
160
  # @return [void]
122
161
  # @raise [Sevgi::Geometry::Error] when c is not a finite Numeric
123
- def initialize(c) = super(slope: 0.0, intercept: c)
162
+ def initialize(c)
163
+ super()
164
+
165
+ @slope = 0.0
166
+ @intercept = Real[:y, c]
167
+ end
124
168
 
125
169
  # Returns an equation rounded to precision.
126
170
  # @param precision [Integer, nil] decimal precision, or nil for the current function default
127
171
  # @return [Sevgi::Geometry::Equation::Linear::Horizontal]
128
172
  def approx(precision = nil) = self.class.new(F.approx(intercept, precision))
129
173
 
174
+ # Reports strict equation equality.
175
+ # @param other [Object] object to compare
176
+ # @return [Boolean]
177
+ def eql?(other) = Nonvertical.equal?(self, other)
178
+
179
+ # Returns a hash compatible with strict equality.
180
+ # @return [Integer]
181
+ def hash = Nonvertical.hash(self)
182
+
183
+ # Reports whether a point is on the line.
184
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
185
+ # @return [Boolean]
186
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
187
+ def on?(point) = Nonvertical.on?(self, point)
188
+
130
189
  # Returns a parallel horizontal equation shifted by offsets.
131
190
  #
132
191
  # A positive signed distance shifts upward in screen coordinates.
@@ -134,19 +193,36 @@ module Sevgi
134
193
  # @param dx [Numeric, nil] accepted for signature compatibility and ignored
135
194
  # @param dy [Numeric, nil] explicit y translation
136
195
  # @return [Sevgi::Geometry::Equation::Linear::Horizontal]
196
+ # @raise [Sevgi::Geometry::Error] when an operand is not a finite real number
137
197
  def shift(distance = nil, dx: nil, dy: nil)
138
- _dx = dx
198
+ distance, _dx, dy = shift_values(distance, dx, dy)
139
199
 
140
- self.class.new(intercept + (dy || 0.0) - (distance || 0.0))
200
+ self.class.new(intercept + dy - distance)
141
201
  end
142
202
 
203
+ # Rejects x lookup because a horizontal equation does not determine one x coordinate.
204
+ # @param _y [Numeric] y coordinate
205
+ # @return [void]
206
+ # @raise [Sevgi::Geometry::Error] always, because x is indeterminate
207
+ def x(_y) = Error.("x is indeterminate for a horizontal equation")
208
+
143
209
  # Formats the equation for display.
144
210
  # @return [String]
145
211
  def to_s = "Linear<y = #{F.approx(intercept)}>"
212
+
213
+ # Evaluates y for an x coordinate.
214
+ # @param x [Numeric] x coordinate
215
+ # @return [Float]
216
+ # @raise [Sevgi::Geometry::Error] when x is not a finite real number
217
+ def y(x) = Nonvertical.y(self, x)
218
+
219
+ alias == eql?
146
220
  end
147
221
 
148
222
  # Vertical linear equation in `x = c` form.
149
223
  class Vertical < Linear
224
+ public_class_method :new
225
+
150
226
  # Creates a vertical equation.
151
227
  # @param c [Numeric] x coordinate
152
228
  # @return [void]
@@ -171,16 +247,6 @@ module Sevgi
171
247
  # @return [Integer]
172
248
  def hash = [self.class, x].hash
173
249
 
174
- # Reports whether a point is on the left side of the line.
175
- # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
176
- # @return [Boolean]
177
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
178
- def left?(point)
179
- point = Tuple[Point, point]
180
-
181
- F.lt?(point.x, x(point.y))
182
- end
183
-
184
250
  # Reports whether a point is on the line.
185
251
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
186
252
  # @return [Boolean]
@@ -191,16 +257,6 @@ module Sevgi
191
257
  F.eq?(point.x, x(point.y))
192
258
  end
193
259
 
194
- # Reports whether a point is on the right side of the line.
195
- # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
196
- # @return [Boolean]
197
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
198
- def right?(point)
199
- point = Tuple[Point, point]
200
-
201
- F.gt?(point.x, x(point.y))
202
- end
203
-
204
260
  # Returns a parallel vertical equation shifted by offsets.
205
261
  #
206
262
  # A positive signed distance shifts right in screen coordinates.
@@ -208,10 +264,11 @@ module Sevgi
208
264
  # @param dx [Numeric, nil] explicit x translation
209
265
  # @param dy [Numeric, nil] accepted for signature compatibility and ignored
210
266
  # @return [Sevgi::Geometry::Equation::Linear::Vertical]
267
+ # @raise [Sevgi::Geometry::Error] when an operand is not a finite real number
211
268
  def shift(distance = nil, dx: nil, dy: nil)
212
- _dy = dy
269
+ distance, dx, _dy = shift_values(distance, dx, dy)
213
270
 
214
- self.class.new(x + (distance || 0.0) + (dx || 0.0))
271
+ self.class.new(x + distance + dx)
215
272
  end
216
273
 
217
274
  # Formats the equation for display.
@@ -219,14 +276,19 @@ module Sevgi
219
276
  def to_s = "Linear<x = #{F.approx(x)}>"
220
277
 
221
278
  # Evaluates x for a y coordinate.
222
- # @param _ [Numeric, nil] ignored y coordinate
279
+ # @param y [Numeric, nil] ignored finite y coordinate
223
280
  # @return [Float]
224
- def x(_ = nil) = @x
281
+ # @raise [Sevgi::Geometry::Error] when y is present and not a finite real number
282
+ def x(y = nil)
283
+ Real[:y, y] unless y.nil?
284
+ @x
285
+ end
225
286
 
226
287
  # Evaluates y for an x coordinate.
227
- # @param _ [Numeric, nil] ignored x coordinate
228
- # @return [Float] positive infinity because a vertical line has no single y
229
- def y(_ = nil) = ::Float::INFINITY
288
+ # @param _x [Numeric] x coordinate
289
+ # @return [void]
290
+ # @raise [Sevgi::Geometry::Error] always, because y is indeterminate
291
+ def y(_x) = Error.("y is indeterminate for a vertical equation")
230
292
 
231
293
  alias == eql?
232
294
  end
@@ -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
@@ -2,13 +2,25 @@
2
2
 
3
3
  module Sevgi
4
4
  module Geometry
5
- # Base class for geometric equations that can intersect with each other.
5
+ # Abstract base class for geometry equations used in boundary intersections.
6
+ #
7
+ # Public factories build linear and implicit quadratic equations.
8
+ # Linear/quadratic intersection is supported in either order. {#intersect} returns an Array: no intersection is
9
+ # `[]`, while one crossing is a one-item Array. Parallel and coincident lines return an empty Array.
10
+ # Linear equation construction and intersection do not round angles or slopes to the display precision.
11
+ # @example Intersect two linear equations
12
+ # diagonal = Sevgi::Geometry::Equation.diagonal(slope: 1, intercept: 0)
13
+ # vertical = Sevgi::Geometry::Equation.vertical(3)
14
+ # diagonal.intersect(vertical).map(&:deconstruct) # => [[3.0, 3.0]]
15
+ # @see Sevgi::Geometry::Element#intersection
6
16
  class Equation
17
+ private_class_method :new
18
+
7
19
  # Builds a non-axis-aligned linear equation.
8
20
  # @param slope [Numeric] line slope
9
21
  # @param intercept [Numeric] y-intercept
10
22
  # @return [Sevgi::Geometry::Equation::Linear::Diagonal]
11
- # @raise [Sevgi::Geometry::Error] when a coefficient is not a finite Numeric
23
+ # @raise [Sevgi::Geometry::Error] when a coefficient is not finite Numeric or slope is zero
12
24
  def self.diagonal(slope:, intercept:) = Linear::Diagonal.new(slope:, intercept:)
13
25
 
14
26
  # Builds a horizontal linear equation.
@@ -23,10 +35,18 @@ module Sevgi
23
35
  # @raise [Sevgi::Geometry::Error] when const is not a finite Numeric
24
36
  def self.vertical(const) = Linear::Vertical.new(const)
25
37
 
38
+ # Builds an implicit quadratic carrier around an optional local origin.
39
+ # Coefficients follow `a*x*x + b*x*y + c*y*y + d*x + e*y + f = 0` in coordinates relative to origin.
40
+ # @param coefficients [Array<Numeric>] six finite coefficients in a, b, c, d, e, f order
41
+ # @param origin [Sevgi::Geometry::Point, Array<Numeric>] local coordinate origin
42
+ # @return [Sevgi::Geometry::Equation::Quadratic]
43
+ # @raise [Sevgi::Geometry::Error] when coefficients or origin are invalid, or the degree is less than two
44
+ def self.quadratic(*coefficients, origin: Origin) = Quadratic.new(*coefficients, origin:)
45
+
26
46
  # Intersects this equation with another equation.
27
47
  # @param other [Sevgi::Geometry::Equation] equation to intersect with
28
48
  # @return [Array<Sevgi::Geometry::Point>] intersection points
29
- # @raise [Sevgi::Geometry::Error] when other is not an equation
49
+ # @raise [Sevgi::Geometry::Error] when other is not an equation or quadratic/quadratic intersection is requested
30
50
  # @raise [Sevgi::PanicError] when the equation combination is not implemented
31
51
  def intersect(other)
32
52
  Error.("Must be an equation: #{other}") unless other.is_a?(Equation)
@@ -36,6 +56,8 @@ module Sevgi
36
56
  linear_vs_linear(other)
37
57
  in [Linear, Quadratic]
38
58
  linear_vs_quadratic(other)
59
+ in [Quadratic, Linear]
60
+ other.intersect(self)
39
61
  in [Quadratic, Quadratic]
40
62
  quadratic_vs_quadratic(other)
41
63
  else
@@ -48,64 +70,83 @@ module Sevgi
48
70
  # Evaluates y for an x coordinate.
49
71
  # @abstract Subclasses implement equation-specific mapping.
50
72
  # @param _x [Numeric] x coordinate
51
- # @return [Float]
73
+ # @return [Float, Array<Float>] one value for a linear equation, or zero to two roots for a quadratic
52
74
  # @raise [Sevgi::PanicError] when a subclass does not implement y
53
75
  def y(_x, ...) = PanicError.("#{self.class}#y must be implemented")
54
76
 
55
77
  private
56
78
 
57
79
  def linear_vs_linear(other)
58
- case [self, other]
59
- in [Linear::Diagonal, Linear::Diagonal]
60
- diagonal_vs_diagonal(self, other)
61
- in [Linear::Diagonal, Linear::Vertical]
62
- diagonal_vs_vertical(self, other)
63
- in [Linear::Vertical, Linear::Diagonal]
64
- diagonal_vs_vertical(other, self)
65
- in [Linear::Vertical, Linear::Vertical]
80
+ case [linear_category(self), linear_category(other)]
81
+ in [:vertical, :vertical]
66
82
  nil
83
+ in [:vertical, :nonvertical]
84
+ nonvertical_vs_vertical(other, self)
85
+ in [:nonvertical, :vertical]
86
+ nonvertical_vs_vertical(self, other)
87
+ in [:nonvertical, :nonvertical]
88
+ nonvertical_vs_nonvertical(self, other)
67
89
  end
68
90
  end
69
91
 
70
- def diagonal_vs_diagonal(left, right)
71
- return nil if F.eq?(left.slope, right.slope)
92
+ def linear_category(equation)
93
+ return :vertical if equation.is_a?(Linear::Vertical)
94
+ return :nonvertical if equation.is_a?(Linear::Diagonal) || equation.is_a?(Linear::Horizontal)
95
+
96
+ PanicError.("Linear equation category not implemented: #{equation.class}")
97
+ end
98
+
99
+ def nonvertical_vs_nonvertical(left, right)
100
+ return nil if left.slope == right.slope
72
101
 
73
102
  x = (right.intercept - left.intercept) / (left.slope - right.slope)
74
103
 
75
104
  Point[x, left.y(x)]
76
105
  end
77
106
 
78
- def diagonal_vs_vertical(diagonal, vertical)
107
+ def nonvertical_vs_vertical(nonvertical, vertical)
79
108
  x = vertical.x
80
109
 
81
- Point[x, diagonal.y(x)]
110
+ Point[x, nonvertical.y(x)]
82
111
  end
83
112
 
84
- def linear_vs_quadratic(...)
85
- PanicError.("Linear/quadratic intersection must be implemented")
86
- end
113
+ def linear_vs_quadratic(other) = other.send(:intersect_linear, self)
87
114
 
88
115
  def quadratic_vs_quadratic(...)
89
- PanicError.("Quadratic/quadratic intersection must be implemented")
116
+ Error.("Quadratic/quadratic intersection is not supported")
90
117
  end
91
118
  end
92
119
 
93
120
  class Point
94
121
  # Returns the linear equation passing through this point at an angle.
122
+ # @example Create axis-aligned equations through a point
123
+ # point = Sevgi::Geometry::Point[4, 3]
124
+ # point.equation(0).y(20) # => 3.0
125
+ # point.equation(90).x(20) # => 4.0
95
126
  # @param angle [Numeric] clockwise angle in degrees
96
127
  # @return [Sevgi::Geometry::Equation::Linear]
128
+ # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
97
129
  def equation(angle)
98
- return Equation.horizontal(y) if F.zero?(angle % 180.0)
99
- return Equation.vertical(x) if F.zero?(angle % 90.0)
130
+ angle = Real[:angle, angle]
131
+ return Equation.horizontal(y) if (angle % 180.0).zero?
132
+ return Equation.vertical(x) if (angle % 90.0).zero?
100
133
 
101
134
  Equation.diagonal(slope: (slope = F.tan(angle)), intercept: y - (slope * x))
102
135
  end
103
136
  end
104
137
 
105
138
  class Line
106
- # Returns the linear equation containing this line.
139
+ # Returns the linear equation through the stored endpoints without a polar conversion.
107
140
  # @return [Sevgi::Geometry::Equation::Linear]
108
- def equation = position.equation(angle)
141
+ def equation
142
+ x, y = starting.deconstruct
143
+ dx, dy = ending.x - x, ending.y - y
144
+ return Equation.horizontal(y) if dy.zero?
145
+ return Equation.vertical(x) if dx.zero?
146
+
147
+ slope = dy / dx
148
+ Equation.diagonal(slope:, intercept: y - (slope * x))
149
+ end
109
150
  end
110
151
 
111
152
  require_relative "equation/linear"
@@ -11,8 +11,8 @@ module Sevgi
11
11
  # @return [Float] finite float value
12
12
  # @raise [Sevgi::Geometry::Error] when value is not a Numeric, cannot be converted to Float, or is not finite
13
13
  def self.[](field, value)
14
- unless value.is_a?(::Numeric)
15
- Error.("Geometry #{field} must be a finite Numeric: #{value.inspect}")
14
+ unless value.is_a?(::Numeric) && !value.is_a?(::Complex)
15
+ Error.("Geometry #{field} must be a finite real Numeric: #{value.inspect}")
16
16
  end
17
17
 
18
18
  number = coerce(field, value)
@@ -27,7 +27,7 @@ module Sevgi
27
27
  def self.coerce(field, value)
28
28
  value.to_f
29
29
  rescue ::StandardError => e
30
- Error.("Geometry #{field} must be a finite Numeric: #{value.inspect} (#{e.message})")
30
+ Error.("Geometry #{field} must be a finite real Numeric: #{value.inspect} (#{e.message})")
31
31
  end
32
32
 
33
33
  private_class_method :coerce
@@ -35,6 +35,14 @@ module Sevgi
35
35
 
36
36
  private_constant :Real
37
37
 
38
+ # Calculates the scalar cross product of two planar component pairs.
39
+ # @api private
40
+ module Cross
41
+ def self.[](ax, ay, bx, by) = (ax * by) - (ay * bx)
42
+ end
43
+
44
+ private_constant :Cross
45
+
38
46
  # Coerces array-like geometry inputs into typed tuple objects.
39
47
  # @api private
40
48
  module Tuple
@@ -4,6 +4,7 @@ module Sevgi
4
4
  module Geometry
5
5
  module Operation
6
6
  # Alignment operation implementation.
7
+ # @api private
7
8
  module Align
8
9
  extend self
9
10
 
@@ -34,10 +35,7 @@ module Sevgi
34
35
 
35
36
  case alignment
36
37
  when :center
37
- Point[
38
- that.position.x + ((that.width - this.width) / 2.0) - this.position.x,
39
- that.position.y + ((that.height - this.height) / 2.0) - this.position.y
40
- ]
38
+ Point[that.center.x - this.center.x, that.center.y - this.center.y]
41
39
  when :left
42
40
  Point[that.position.x - this.position.x, 0]
43
41
  when :right