sevgi-geometry 0.94.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,26 +3,80 @@
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
10
- # @!attribute [r] slope
11
- # @return [Float] line slope
12
- # @!attribute [r] intercept
13
- # @return [Float] y-intercept
14
- attr_reader :slope, :intercept
59
+ public_class_method :new
60
+
61
+ # Returns the line slope.
62
+ # @return [Float]
63
+ attr_reader :slope
64
+
65
+ # Returns the y-intercept.
66
+ # @return [Float]
67
+ attr_reader :intercept
15
68
 
16
69
  # Creates a diagonal linear equation.
17
70
  # @param slope [Numeric] line slope
18
71
  # @param intercept [Numeric] y-intercept
19
72
  # @return [void]
20
- # @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
21
74
  def initialize(slope:, intercept:)
22
75
  super()
23
76
 
24
77
  @slope = Real[:slope, slope]
25
78
  @intercept = Real[:intercept, intercept]
79
+ Error.("A diagonal equation requires a non-zero slope") if @slope.zero?
26
80
  end
27
81
 
28
82
  # Returns an equation rounded to precision.
@@ -35,55 +89,30 @@ module Sevgi
35
89
  # Reports strict equation equality.
36
90
  # @param other [Object] object to compare
37
91
  # @return [Boolean]
38
- def eql?(other) = self.class == other.class && [slope, intercept] == [other.slope, other.intercept]
92
+ def eql?(other) = Nonvertical.equal?(self, other)
39
93
 
40
94
  # Returns a hash compatible with strict equality.
41
95
  # @return [Integer]
42
- def hash = [self.class, slope, intercept].hash
43
-
44
- # Reports whether a point is on the left side of the line in screen coordinates.
45
- # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
46
- # @return [Boolean]
47
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
48
- def left?(point)
49
- point = Tuple[Point, point]
50
-
51
- F.gt?(point.y, y(point.x))
52
- end
96
+ def hash = Nonvertical.hash(self)
53
97
 
54
98
  # Reports whether a point is on the line.
55
99
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
56
100
  # @return [Boolean]
57
101
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
58
- def on?(point)
59
- point = Tuple[Point, point]
60
-
61
- F.eq?(point.y, y(point.x))
62
- end
63
-
64
- # Reports whether a point is on the right side of the line in screen coordinates.
65
- # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
66
- # @return [Boolean]
67
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
68
- def right?(point)
69
- point = Tuple[Point, point]
70
-
71
- F.lt?(point.y, y(point.x))
72
- end
102
+ def on?(point) = Nonvertical.on?(self, point)
73
103
 
74
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.
75
106
  # @param distance [Numeric, nil] signed perpendicular offset
76
107
  # @param dx [Numeric, nil] explicit x translation
77
108
  # @param dy [Numeric, nil] explicit y translation
78
109
  # @return [Sevgi::Geometry::Equation::Linear::Diagonal]
110
+ # @raise [Sevgi::Geometry::Error] when an operand is not a finite real number
79
111
  def shift(distance = nil, dx: nil, dy: nil)
80
- dx ||= 0.0
81
- dy ||= 0.0
82
-
83
- if distance
84
- dx += distance * F.sin(angle = F.atan(slope))
85
- dy -= distance * F.cos(angle)
86
- 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)
87
116
 
88
117
  Diagonal.new(slope:, intercept: intercept - (slope * dx) + dy)
89
118
  end
@@ -102,29 +131,61 @@ module Sevgi
102
131
  # Evaluates x for a y coordinate.
103
132
  # @param y [Numeric] y coordinate
104
133
  # @return [Float]
105
- 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
106
136
 
107
137
  # Evaluates y for an x coordinate.
108
138
  # @param x [Numeric] x coordinate
109
139
  # @return [Float]
110
- 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)
111
142
 
112
143
  alias == eql?
113
144
  end
114
145
 
115
146
  # Horizontal linear equation in `y = c` form.
116
- 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
+
117
158
  # Creates a horizontal equation.
118
159
  # @param c [Numeric] y coordinate
119
160
  # @return [void]
120
161
  # @raise [Sevgi::Geometry::Error] when c is not a finite Numeric
121
- 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
122
168
 
123
169
  # Returns an equation rounded to precision.
124
170
  # @param precision [Integer, nil] decimal precision, or nil for the current function default
125
171
  # @return [Sevgi::Geometry::Equation::Linear::Horizontal]
126
172
  def approx(precision = nil) = self.class.new(F.approx(intercept, precision))
127
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
+
128
189
  # Returns a parallel horizontal equation shifted by offsets.
129
190
  #
130
191
  # A positive signed distance shifts upward in screen coordinates.
@@ -132,19 +193,36 @@ module Sevgi
132
193
  # @param dx [Numeric, nil] accepted for signature compatibility and ignored
133
194
  # @param dy [Numeric, nil] explicit y translation
134
195
  # @return [Sevgi::Geometry::Equation::Linear::Horizontal]
196
+ # @raise [Sevgi::Geometry::Error] when an operand is not a finite real number
135
197
  def shift(distance = nil, dx: nil, dy: nil)
136
- _dx = dx
198
+ distance, _dx, dy = shift_values(distance, dx, dy)
137
199
 
138
- self.class.new(intercept + (dy || 0.0) - (distance || 0.0))
200
+ self.class.new(intercept + dy - distance)
139
201
  end
140
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
+
141
209
  # Formats the equation for display.
142
210
  # @return [String]
143
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?
144
220
  end
145
221
 
146
222
  # Vertical linear equation in `x = c` form.
147
223
  class Vertical < Linear
224
+ public_class_method :new
225
+
148
226
  # Creates a vertical equation.
149
227
  # @param c [Numeric] x coordinate
150
228
  # @return [void]
@@ -169,16 +247,6 @@ module Sevgi
169
247
  # @return [Integer]
170
248
  def hash = [self.class, x].hash
171
249
 
172
- # Reports whether a point is on the left side of the line.
173
- # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
174
- # @return [Boolean]
175
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
176
- def left?(point)
177
- point = Tuple[Point, point]
178
-
179
- F.lt?(point.x, x(point.y))
180
- end
181
-
182
250
  # Reports whether a point is on the line.
183
251
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
184
252
  # @return [Boolean]
@@ -189,16 +257,6 @@ module Sevgi
189
257
  F.eq?(point.x, x(point.y))
190
258
  end
191
259
 
192
- # Reports whether a point is on the right side of the line.
193
- # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
194
- # @return [Boolean]
195
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
196
- def right?(point)
197
- point = Tuple[Point, point]
198
-
199
- F.gt?(point.x, x(point.y))
200
- end
201
-
202
260
  # Returns a parallel vertical equation shifted by offsets.
203
261
  #
204
262
  # A positive signed distance shifts right in screen coordinates.
@@ -206,10 +264,11 @@ module Sevgi
206
264
  # @param dx [Numeric, nil] explicit x translation
207
265
  # @param dy [Numeric, nil] accepted for signature compatibility and ignored
208
266
  # @return [Sevgi::Geometry::Equation::Linear::Vertical]
267
+ # @raise [Sevgi::Geometry::Error] when an operand is not a finite real number
209
268
  def shift(distance = nil, dx: nil, dy: nil)
210
- _dy = dy
269
+ distance, dx, _dy = shift_values(distance, dx, dy)
211
270
 
212
- self.class.new(x + (distance || 0.0) + (dx || 0.0))
271
+ self.class.new(x + distance + dx)
213
272
  end
214
273
 
215
274
  # Formats the equation for display.
@@ -217,14 +276,19 @@ module Sevgi
217
276
  def to_s = "Linear<x = #{F.approx(x)}>"
218
277
 
219
278
  # Evaluates x for a y coordinate.
220
- # @param _ [Numeric, nil] ignored y coordinate
279
+ # @param y [Numeric, nil] ignored finite y coordinate
221
280
  # @return [Float]
222
- 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
223
286
 
224
287
  # Evaluates y for an x coordinate.
225
- # @param _ [Numeric, nil] ignored x coordinate
226
- # @return [Float] positive infinity because a vertical line has no single y
227
- 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")
228
292
 
229
293
  alias == eql?
230
294
  end
@@ -3,9 +3,12 @@
3
3
  module Sevgi
4
4
  module Geometry
5
5
  class Equation
6
- # Base class for quadratic equations.
6
+ # Base class reserved for future quadratic equation support.
7
+ # @api private
7
8
  class Quadratic < Equation
8
9
  end
10
+
11
+ private_constant :Quadratic
9
12
  end
10
13
 
11
14
  # Reserved circle element support.
@@ -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)
@@ -2,23 +2,35 @@
2
2
 
3
3
  module Sevgi
4
4
  module Geometry
5
- # Validates finite real numeric inputs for geometry primitives.
5
+ # Validates numeric inputs that can be represented as finite Floats.
6
6
  # @api private
7
7
  module Real
8
- # Coerces a public numeric value to Float.
8
+ # Coerces a public numeric value to a finite Float.
9
9
  # @param field [Symbol, String] coordinate or component name
10
10
  # @param value [Object] value to coerce
11
11
  # @return [Float] finite float value
12
- # @raise [Sevgi::Geometry::Error] when value is not a finite Numeric
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
- value.to_f.tap do |number|
19
- Error.("Geometry #{field} must be finite: #{value.inspect}") unless number.finite?
18
+ number = coerce(field, value)
19
+
20
+ unless number.is_a?(::Float) && number.finite?
21
+ Error.("Geometry #{field} must be finite: #{value.inspect}")
20
22
  end
23
+
24
+ number
21
25
  end
26
+
27
+ def self.coerce(field, value)
28
+ value.to_f
29
+ rescue ::StandardError => e
30
+ Error.("Geometry #{field} must be a finite real Numeric: #{value.inspect} (#{e.message})")
31
+ end
32
+
33
+ private_class_method :coerce
22
34
  end
23
35
 
24
36
  private_constant :Real
@@ -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
 
@@ -13,7 +14,7 @@ module Sevgi
13
14
  # Sweeps parallel lines across a lined element in both directions.
14
15
  #
15
16
  # Generated lines are boundary-to-boundary interior spans. A single
16
- # sweep position can produce multiple lines for closed concave elements.
17
+ # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
17
18
  # @param element [Sevgi::Geometry::Element::Lined] element to intersect
18
19
  # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
19
20
  # @param angle [Numeric] clockwise sweep line angle in degrees
@@ -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
  [
@@ -39,7 +41,7 @@ module Sevgi
39
41
  # Sweeps parallel lines across a lined element and requires at least one result.
40
42
  #
41
43
  # Generated lines are boundary-to-boundary interior spans. A single
42
- # sweep position can produce multiple lines for closed concave elements.
44
+ # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
43
45
  # @param element [Sevgi::Geometry::Element::Lined] element to intersect
44
46
  # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
45
47
  # @param angle [Numeric] clockwise sweep line angle in degrees
@@ -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|
@@ -64,14 +66,16 @@ module Sevgi
64
66
  # Sweeps parallel lines in one signed direction from an equation.
65
67
  #
66
68
  # Generated lines are boundary-to-boundary interior spans. A single
67
- # sweep position can produce multiple lines for closed concave elements.
69
+ # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
68
70
  # @param element [Sevgi::Geometry::Element::Lined] element to intersect
69
71
  # @param equation [Sevgi::Geometry::Equation] initial sweep equation
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,7 +102,19 @@ 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)
116
+ return [] unless element.class.send(:close?)
117
+
100
118
  if points.size == 2
101
119
  line = simple_line(points)
102
120
 
@@ -127,7 +145,7 @@ module Sevgi
127
145
  end
128
146
  end
129
147
 
130
- register(Sweep, :sweep, :sweep!, :unisweep)
148
+ register(Sweep, :sweep, :sweep!)
131
149
 
132
150
  private_constant :Sweep
133
151
  end