sevgi-geometry 0.95.0 → 0.98.2

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.
@@ -7,7 +7,48 @@ module Sevgi
7
7
  TriangleBase = Element.lined(3)
8
8
  private_constant :TriangleBase
9
9
 
10
- # Closed three-sided element built from two non-collinear adjacent segments.
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.
12
+ # @!method self.call(*points)
13
+ # Builds a triangle from three boundary points.
14
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] three boundary points
15
+ # @return [Sevgi::Geometry::Triangle]
16
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or form a degenerate triangle
17
+ # @!method self.from_segments(segment_a, segment_b, position: Origin)
18
+ # Builds a triangle from two adjacent segments and derives the closing side.
19
+ # @param segment_a [Sevgi::Geometry::Segment, Array<Numeric>] first adjacent segment
20
+ # @param segment_b [Sevgi::Geometry::Segment, Array<Numeric>] second adjacent segment
21
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
22
+ # @return [Sevgi::Geometry::Triangle]
23
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or form a degenerate triangle
24
+ # @!method self.from_points(*points)
25
+ # Builds a triangle from three boundary points.
26
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] three boundary points
27
+ # @return [Sevgi::Geometry::Triangle]
28
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or form a degenerate triangle
29
+ # @!attribute [r] A
30
+ # @return [Sevgi::Geometry::Point] first vertex
31
+ # @!attribute [r] B
32
+ # @return [Sevgi::Geometry::Point] second vertex
33
+ # @!attribute [r] C
34
+ # @return [Sevgi::Geometry::Point] third vertex
35
+ # @!attribute [r] AB
36
+ # @return [Sevgi::Geometry::Line] side from A to B
37
+ # @!attribute [r] BC
38
+ # @return [Sevgi::Geometry::Line] side from B to C
39
+ # @!attribute [r] CA
40
+ # @return [Sevgi::Geometry::Line] side from C to A
41
+ # @!method perimeter
42
+ # Returns the closed path perimeter.
43
+ # @return [Float]
44
+ # @example Pair mathematical notation with English conveniences
45
+ # Sevgi::Geometry::Triangle[[2, 0], [2, 90]] == Sevgi::Geometry::Triangle.from_segments([2, 0], [2, 90])
46
+ # Sevgi::Geometry::Triangle.([0, 0], [2, 0], [2, 2]) == Sevgi::Geometry::Triangle.from_points([0, 0], [2, 0], [2, 2])
47
+ # @example Use named vertices and sides
48
+ # triangle = Sevgi::Geometry::Triangle.([0, 0], [3, 0], [3, 4])
49
+ # triangle.C.deconstruct # => [3.0, 4.0]
50
+ # triangle.AB.length # => 3.0
51
+ # triangle.perimeter # => 12.0
11
52
  class Triangle < TriangleBase
12
53
  # Builds a triangle from two adjacent segments.
13
54
  #
@@ -27,21 +68,29 @@ module Sevgi
27
68
  new_by_segments(a, b, closing_segment(a, b), position:)
28
69
  end
29
70
 
30
- def self.closing_segment(a, b)
31
- Segment.(b.ending(a.ending(Origin)), Origin)
32
- end
71
+ class << self
72
+ private
73
+
74
+ def closing_segment(a, b)
75
+ Segment.(b.ending(a.ending(Origin)), Origin)
76
+ end
33
77
 
34
- def self.cross(a, b) = (a.x * b.y) - (a.y * b.x)
78
+ def cross(a, b) = (a.x * b.y) - (a.y * b.x)
35
79
 
36
- def self.validate!(a, b)
37
- if F.zero?(a.length) ||
38
- F.zero?(b.length) ||
39
- F.zero?(cross(a, b))
40
- Error.("Triangle segments must form a non-degenerate triangle")
80
+ 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
41
86
  end
42
87
  end
43
88
 
44
- private_class_method :closing_segment, :cross, :validate!
89
+ private
90
+
91
+ def validate_geometry!
92
+ self.class.send(:validate!, segments[0], segments[1])
93
+ end
45
94
  end
46
95
  end
47
96
  end
@@ -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
@@ -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
+ # The supported public factories build horizontal, vertical, and diagonal
8
+ # linear equations. {#intersect} always returns an Array: no intersection is
9
+ # `[]`, while one crossing is a one-item Array. Coincident parallel lines do
10
+ # not represent a finite intersection and also return an empty Array.
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::Lined#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.
@@ -55,19 +67,26 @@ module Sevgi
55
67
  private
56
68
 
57
69
  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]
70
+ case [linear_category(self), linear_category(other)]
71
+ in [:vertical, :vertical]
66
72
  nil
73
+ in [:vertical, :nonvertical]
74
+ nonvertical_vs_vertical(other, self)
75
+ in [:nonvertical, :vertical]
76
+ nonvertical_vs_vertical(self, other)
77
+ in [:nonvertical, :nonvertical]
78
+ nonvertical_vs_nonvertical(self, other)
67
79
  end
68
80
  end
69
81
 
70
- def diagonal_vs_diagonal(left, right)
82
+ def linear_category(equation)
83
+ return :vertical if equation.is_a?(Linear::Vertical)
84
+ return :nonvertical if equation.is_a?(Linear::Diagonal) || equation.is_a?(Linear::Horizontal)
85
+
86
+ PanicError.("Linear equation category not implemented: #{equation.class}")
87
+ end
88
+
89
+ def nonvertical_vs_nonvertical(left, right)
71
90
  return nil if F.eq?(left.slope, right.slope)
72
91
 
73
92
  x = (right.intercept - left.intercept) / (left.slope - right.slope)
@@ -75,10 +94,10 @@ module Sevgi
75
94
  Point[x, left.y(x)]
76
95
  end
77
96
 
78
- def diagonal_vs_vertical(diagonal, vertical)
97
+ def nonvertical_vs_vertical(nonvertical, vertical)
79
98
  x = vertical.x
80
99
 
81
- Point[x, diagonal.y(x)]
100
+ Point[x, nonvertical.y(x)]
82
101
  end
83
102
 
84
103
  def linear_vs_quadratic(...)
@@ -92,6 +111,10 @@ module Sevgi
92
111
 
93
112
  class Point
94
113
  # Returns the linear equation passing through this point at an angle.
114
+ # @example Create axis-aligned equations through a point
115
+ # point = Sevgi::Geometry::Point[4, 3]
116
+ # point.equation(0).y(20) # => 3.0
117
+ # point.equation(90).x(20) # => 4.0
95
118
  # @param angle [Numeric] clockwise angle in degrees
96
119
  # @return [Sevgi::Geometry::Equation::Linear]
97
120
  def equation(angle)
@@ -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
@@ -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
 
@@ -4,6 +4,7 @@ module Sevgi
4
4
  module Geometry
5
5
  module Operation
6
6
  # Sweep operation implementation.
7
+ # @api private
7
8
  module Sweep
8
9
  extend self
9
10
 
@@ -23,9 +24,10 @@ module Sevgi
23
24
  # @yieldparam lines [Array<Sevgi::Geometry::Line>] generated sweep lines
24
25
  # @yieldreturn [void]
25
26
  # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
26
- # @raise [Sevgi::Geometry::Error] when initial cannot be coerced
27
+ # @raise [Sevgi::Geometry::Error] when initial, angle, step, or limit is invalid
27
28
  # @raise [Sevgi::Geometry::Operation::OperationError] when iteration reaches the limit
28
29
  def sweep(element, initial:, angle:, step:, limit: LIMIT, &block)
30
+ step = validate_arguments(step, limit)
29
31
  equation = Tuple[Point, initial].equation(angle)
30
32
 
31
33
  [
@@ -49,7 +51,7 @@ module Sevgi
49
51
  # @yieldparam lines [Array<Sevgi::Geometry::Line>] generated sweep lines
50
52
  # @yieldreturn [void]
51
53
  # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
52
- # @raise [Sevgi::Geometry::Error] when initial cannot be coerced
54
+ # @raise [Sevgi::Geometry::Error] when initial, angle, step, or limit is invalid
53
55
  # @raise [Sevgi::Geometry::Operation::OperationError] when no lines are found or iteration reaches the limit
54
56
  def sweep!(element, initial:, angle:, step:, limit: LIMIT, &block)
55
57
  sweep(element, initial:, angle:, step:, limit:) do |lines|
@@ -70,8 +72,10 @@ module Sevgi
70
72
  # @param step [Numeric] signed distance between sweep lines
71
73
  # @param limit [Integer] maximum iterations
72
74
  # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
75
+ # @raise [Sevgi::Geometry::Error] when step or limit is invalid
73
76
  # @raise [Sevgi::Geometry::Operation::OperationError] when iteration reaches the limit
74
77
  def unisweep(element, equation, step, limit: LIMIT)
78
+ step = validate_arguments(step, limit)
75
79
  lines = []
76
80
 
77
81
  limit.times do
@@ -86,6 +90,8 @@ module Sevgi
86
90
  OperationError.("Loop limit reached: #{limit}")
87
91
  end
88
92
 
93
+ private :unisweep
94
+
89
95
  # Reports whether the sweep handler can operate on an element.
90
96
  # @api private
91
97
  # @param element [Object] candidate element
@@ -96,8 +102,18 @@ module Sevgi
96
102
 
97
103
  private
98
104
 
105
+ def validate_arguments(step, limit)
106
+ step = Real[:step, step]
107
+ Error.("Sweep step must be nonzero") if step.zero?
108
+ unless limit.is_a?(::Integer) && limit.positive?
109
+ Error.("Sweep limit must be a positive Integer: #{limit.inspect}")
110
+ end
111
+
112
+ step
113
+ end
114
+
99
115
  def interior_lines(element, equation, points)
100
- return [] if element.class.respond_to?(:open?) && element.class.open?
116
+ return [] unless element.class.send(:close?)
101
117
 
102
118
  if points.size == 2
103
119
  line = simple_line(points)
@@ -129,7 +145,7 @@ module Sevgi
129
145
  end
130
146
  end
131
147
 
132
- register(Sweep, :sweep, :sweep!, :unisweep)
148
+ register(Sweep, :sweep, :sweep!)
133
149
 
134
150
  private_constant :Sweep
135
151
  end