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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +221 -2
- data/README.md +33 -8
- data/lib/sevgi/geometry/element.rb +320 -161
- 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 +68 -23
- data/lib/sevgi/geometry/elements/parallelogram.rb +108 -29
- data/lib/sevgi/geometry/elements/polygon.rb +41 -1
- data/lib/sevgi/geometry/elements/polyline.rb +45 -1
- data/lib/sevgi/geometry/elements/rect.rb +163 -29
- data/lib/sevgi/geometry/elements/triangle.rb +57 -11
- data/lib/sevgi/geometry/equation/linear.rb +131 -69
- data/lib/sevgi/geometry/equation/quadratic.rb +98 -16
- data/lib/sevgi/geometry/equation.rb +65 -24
- data/lib/sevgi/geometry/internal.rb +11 -3
- data/lib/sevgi/geometry/operation/align.rb +2 -4
- data/lib/sevgi/geometry/operation/box.rb +50 -0
- data/lib/sevgi/geometry/operation/sweep.rb +32 -18
- data/lib/sevgi/geometry/operation.rb +33 -18
- data/lib/sevgi/geometry/point.rb +126 -14
- data/lib/sevgi/geometry/predicate.rb +176 -0
- data/lib/sevgi/geometry/segment.rb +79 -14
- data/lib/sevgi/geometry/version.rb +1 -1
- data/lib/sevgi/geometry.rb +26 -2
- metadata +13 -6
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 <
|
|
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)
|
|
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 +
|
|
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 +
|
|
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
|
|
279
|
+
# @param y [Numeric, nil] ignored finite y coordinate
|
|
223
280
|
# @return [Float]
|
|
224
|
-
|
|
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
|
|
228
|
-
# @return [
|
|
229
|
-
|
|
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
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
12
|
+
public_class_method :new
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
#
|
|
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
|
|
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 [
|
|
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
|
|
71
|
-
return
|
|
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
|
|
107
|
+
def nonvertical_vs_vertical(nonvertical, vertical)
|
|
79
108
|
x = vertical.x
|
|
80
109
|
|
|
81
|
-
Point[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
|
-
|
|
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
|
-
|
|
99
|
-
return Equation.
|
|
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
|
|
139
|
+
# Returns the linear equation through the stored endpoints without a polar conversion.
|
|
107
140
|
# @return [Sevgi::Geometry::Equation::Linear]
|
|
108
|
-
def equation
|
|
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
|