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.
@@ -2,14 +2,25 @@
2
2
 
3
3
  module Sevgi
4
4
  module Geometry
5
- # Base class for geometric elements.
5
+ # Abstract base class for positioned geometry values.
6
+ #
7
+ # Construct concrete shapes through their class factories. Element
8
+ # operations return new values, and {#box} supplies the axis-aligned bounds
9
+ # used by alignment and tiling helpers.
6
10
  class Element
11
+ private_class_method :new
12
+
7
13
  # @overload lined(size = Undefined, open: false)
8
14
  # Builds a lined element subclass.
15
+ # Instances expose total path `length`; closed classes additionally expose `perimeter`.
9
16
  # @param size [Integer, Sevgi::Undefined] segment count for fixed-size elements, or Undefined for variable size
10
17
  # @param open [Boolean] true for an open path, false for a closed path
11
18
  # @return [Class] subclass of {Sevgi::Geometry::Element::Lined}
12
- def self.lined(...) = Lined.build(...)
19
+ # @raise [Sevgi::Geometry::Error] when size is not Undefined or a positive Integer, or open is not Boolean
20
+ # @example Define a custom two-segment open shape
21
+ # Path = Sevgi::Geometry::Element.lined(2, open: true)
22
+ # Path.([0, 0], [1, 0], [1, 1])
23
+ def self.lined(...) = Lined.send(:build, ...)
13
24
 
14
25
  # @overload arced(*args)
15
26
  # Builds an arced element subclass.
@@ -24,13 +35,20 @@ module Sevgi
24
35
  # Core API
25
36
 
26
37
  # Returns a copy moved to a point and optional offset.
38
+ # @example Position a copy without mutating the source
39
+ # source = Sevgi::Geometry::Rect[8, 4, position: [1, 2]]
40
+ # moved = source.at([10, 20], dx: 2)
41
+ # source.position.deconstruct # => [1.0, 2.0]
42
+ # moved.position.deconstruct # => [12.0, 20.0]
27
43
  # @param point [Sevgi::Geometry::Point, Array<Numeric>, nil] target position, or nil to keep current position
28
44
  # @param dx [Numeric] additional x offset
29
45
  # @param dy [Numeric] additional y offset
30
46
  # @return [Sevgi::Geometry::Element] translated element
31
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
47
+ # @raise [Sevgi::Geometry::Error] when point or an offset cannot be coerced to finite geometry values
32
48
  def at(point = nil, dx: 0, dy: 0)
33
49
  point = point ? Tuple[Point, point] : position
50
+ dx = Real[:dx, dx]
51
+ dy = Real[:dy, dy]
34
52
 
35
53
  translate(
36
54
  (point.x - position.x) + dx,
@@ -69,18 +87,32 @@ module Sevgi
69
87
  # @raise [Sevgi::PanicError] when a subclass does not implement translate
70
88
  def translate(_x, _y) = PanicError.("#{self.class}#translate must be implemented")
71
89
 
72
- # rubocop:disable Metrics/ClassLength
73
90
  # Element whose boundary is represented by straight segments.
91
+ #
92
+ # The same path is available as immutable {#points}, {#segments}, and
93
+ # {#lines} collections. Closed shapes repeat their first point at the end;
94
+ # open paths do not. Only closed shapes have a filled interior, so
95
+ # `inside?` on an open path is equivalent to testing its boundary.
96
+ # @example Inspect the interchangeable point, segment, and line views
97
+ # rect = Sevgi::Geometry::Rect[8, 4]
98
+ # rect.points.size # => 5
99
+ # rect.segments.size # => 4
100
+ # rect.lines.size # => 4
101
+ # @see Sevgi::Geometry::Operation.sweep
74
102
  class Lined < self
75
103
  # Open lined element base class.
104
+ # @api private
76
105
  Open = Class.new(self) do
77
106
  # Draws the element as an SVG polyline.
78
107
  # @param node [Object] graphics node receiving the drawing command
79
108
  # @return [Object] graphics node command result
80
109
  def draw!(node, **) = node.polyline(points: points.map { it.deconstruct.join(",") }, **)
110
+
111
+ private :draw!
81
112
  end
82
113
 
83
114
  # Closed lined element base class.
115
+ # @api private
84
116
  Close = Class.new(self) do
85
117
  # Creates a closed element from points, appending the first point.
86
118
  # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
@@ -88,126 +120,163 @@ module Sevgi
88
120
  # @raise [Sevgi::Geometry::Error] when any point cannot be coerced
89
121
  def self.new_by_points(*points) = super(*points, points.first)
90
122
 
123
+ # Returns the closed path perimeter.
124
+ # @return [Float]
125
+ def perimeter = length
126
+
91
127
  # Draws the element as an SVG polygon.
92
128
  # @param node [Object] graphics node receiving the drawing command
93
129
  # @return [Object] graphics node command result
94
130
  def draw!(node, **) = node.polygon(points: points.map { it.deconstruct.join(",") }, **)
131
+
132
+ private :draw!
133
+
134
+ private_class_method :new_by_points
95
135
  end
96
136
 
97
137
  # Class methods
98
138
 
99
139
  # Point shortcut names generated for fixed-size lined elements.
140
+ # @api private
100
141
  SHORTCUTS = ("A".."Z").to_a.freeze
142
+ private_constant :Close, :Open, :SHORTCUTS
143
+
144
+ class << self
145
+ private
146
+
147
+ # Builds a concrete lined element class.
148
+ # @param size [Integer, Sevgi::Undefined] segment count for fixed-size elements, or Undefined for variable size
149
+ # @param open [Boolean] true for an open path, false for a closed path
150
+ # @return [Class] lined element subclass
151
+ # @raise [Sevgi::Geometry::Error] when size is not Undefined or a positive Integer, or open is not Boolean
152
+ # @api private
153
+ def build(size = Undefined, open: false)
154
+ validate_factory(size, open)
155
+
156
+ klass = Class.new(open ? Open : Close)
157
+ klass.define_singleton_method(:close?) { !open }
158
+ klass.define_singleton_method(:poly?) { size.equal?(Undefined) }
159
+ klass.define_singleton_method(:size) { size }
160
+ define_shortcuts(klass, size, open:) unless size.equal?(Undefined)
161
+ klass.public_class_method(:[], :call, :from_points, :from_segments)
162
+ klass.private_class_method(:close?, :poly?, :size)
163
+ klass
164
+ end
101
165
 
102
- # Builds a concrete lined element class.
103
- # @param size [Integer, Sevgi::Undefined] segment count for fixed-size elements, or Undefined for variable size
104
- # @param open [Boolean] true for an open path, false for a closed path
105
- # @return [Class] lined element subclass
106
- def self.build(size = Undefined, open: false)
107
- Class.new(open ? Open : Close) do
108
- define_singleton_method(:close?) { !open }
109
-
110
- define_singleton_method(:open?) { open }
111
-
112
- define_singleton_method(:poly?) { size == Undefined }
113
-
114
- define_singleton_method(:size) { size }
166
+ def validate_factory(size, open)
167
+ unless size.equal?(Undefined) || (size.is_a?(::Integer) && size.positive?)
168
+ Error.("Lined segment count must be a positive Integer or Undefined")
169
+ end
115
170
 
116
- Lined.send(:define_shortcuts, self, size, open:) unless size == Undefined
171
+ Error.("Lined open flag must be Boolean") unless open.equal?(true) || open.equal?(false)
117
172
  end
118
- end
119
173
 
120
- # @overload [](*segments, position: Origin)
121
- # Builds an element from segments.
122
- # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
123
- # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
124
- # @return [Sevgi::Geometry::Element::Lined]
125
- # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
126
- def self.[](...) = from_segments(...)
127
-
128
- # @overload call(*points)
129
- # Builds an element from points.
130
- # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
131
- # @return [Sevgi::Geometry::Element::Lined]
132
- # @raise [Sevgi::Geometry::Error] when points cannot be coerced
133
- def self.call(...) = from_points(...)
134
-
135
- # @overload from_points(*points)
136
- # Builds an element from points.
137
- # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
138
- # @return [Sevgi::Geometry::Element::Lined]
139
- # @raise [Sevgi::Geometry::Error] when points cannot be coerced
140
- def self.from_points(...) = new_by_points(...)
141
-
142
- # @overload from_segments(*segments, position: Origin)
143
- # Builds an element from segments.
144
- # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
145
- # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
146
- # @return [Sevgi::Geometry::Element::Lined]
147
- # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
148
- def self.from_segments(...) = new_by_segments(...)
149
-
150
- # @overload new_by_points(*points)
151
- # Builds an element from points, applying closed-path behavior where appropriate.
152
- # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
153
- # @return [Sevgi::Geometry::Element::Lined]
154
- # @raise [Sevgi::Geometry::Error] when points cannot be coerced
155
- def self.new_by_points(...) = new_by_points!(...)
156
-
157
- # Builds an element from an exact point path.
158
- #
159
- # Closed classes require the closing point to be supplied by the caller.
160
- # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] exact boundary points
161
- # @return [Sevgi::Geometry::Element::Lined]
162
- # @raise [Sevgi::Geometry::Error] when points cannot be coerced or do not satisfy the class path contract
163
- def self.new_by_points!(*points)
164
- new do
165
- @points = Tuples[Point, *points]
174
+ # @overload [](*segments, position: Origin)
175
+ # Builds an element from segments.
176
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
177
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
178
+ # @return [Sevgi::Geometry::Element::Lined]
179
+ # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
180
+ # @api private
181
+ def [](...) = new_by_segments(...)
182
+
183
+ # @overload call(*points)
184
+ # Builds an element from points.
185
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
186
+ # @return [Sevgi::Geometry::Element::Lined]
187
+ # @raise [Sevgi::Geometry::Error] when points cannot be coerced
188
+ # @api private
189
+ def call(...) = new_by_points(...)
190
+
191
+ # @overload from_points(*points)
192
+ # Builds an element from points.
193
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
194
+ # @return [Sevgi::Geometry::Element::Lined]
195
+ # @raise [Sevgi::Geometry::Error] when points cannot be coerced
196
+ # @api private
197
+ def from_points(...) = call(...)
198
+
199
+ # @overload from_segments(*segments, position: Origin)
200
+ # Builds an element from segments.
201
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
202
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
203
+ # @return [Sevgi::Geometry::Element::Lined]
204
+ # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
205
+ # @api private
206
+ def from_segments(*segments, position: Origin) = self[*segments, position:]
207
+
208
+ def affine(*points) = new_by_points!(*points)
209
+
210
+ def approximate(*points)
211
+ new_by_points!(*points)
212
+ rescue Error
213
+ (close? ? Polygon : Polyline).send(:new_by_points!, *points)
166
214
  end
167
- end
168
215
 
169
- # Builds an element from segments and a start position.
170
- # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
171
- # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
172
- # @return [Sevgi::Geometry::Element::Lined]
173
- # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
174
- def self.new_by_segments(*segments, position: Origin)
175
- new do
176
- @position = Tuple[Point, position]
177
- @segments = Tuples[Segment, *segments]
216
+ # @overload new_by_points(*points)
217
+ # Builds an element from points, applying closed-path behavior where appropriate.
218
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
219
+ # @return [Sevgi::Geometry::Element::Lined]
220
+ # @raise [Sevgi::Geometry::Error] when points cannot be coerced
221
+ # @api private
222
+ def new_by_points(...) = new_by_points!(...)
223
+
224
+ # Builds an element from an exact point path.
225
+ #
226
+ # Closed classes require the closing point to be supplied by the caller.
227
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] exact boundary points
228
+ # @return [Sevgi::Geometry::Element::Lined]
229
+ # @raise [Sevgi::Geometry::Error] when points cannot be coerced or do not satisfy the class path contract
230
+ # @api private
231
+ def new_by_points!(*points)
232
+ new do
233
+ @points = Tuples[Point, *points]
234
+ end
178
235
  end
179
- end
180
236
 
181
- private_class_method(:new)
237
+ # Builds an element from segments and a start position.
238
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
239
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
240
+ # @return [Sevgi::Geometry::Element::Lined]
241
+ # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
242
+ # @api private
243
+ def new_by_segments(*segments, position: Origin)
244
+ new do
245
+ @position = Tuple[Point, position]
246
+ @segments = Tuples[Segment, *segments]
247
+ end
248
+ end
182
249
 
183
- def self.define_line_shortcuts(klass, point_names, open:)
184
- line_names = point_names.each_cons(2).map(&:join)
185
- line_names << "#{point_names.last}#{point_names.first}" if !open && point_names.any?
250
+ def define_line_shortcuts(klass, point_names, closing:)
251
+ line_names = point_names.each_cons(2).map(&:join)
252
+ line_names << "#{point_names.last}#{point_names.first}" if closing
186
253
 
187
- line_names.each_with_index do |name, i|
188
- klass.define_method(name) { lines[i] or Error.("No such line: #{name}") }
254
+ line_names.each_with_index do |name, i|
255
+ klass.define_method(name) { lines[i] or Error.("No such line: #{name}") }
256
+ end
189
257
  end
190
- end
191
258
 
192
- def self.define_point_shortcuts(klass, point_names)
193
- point_names.each_with_index do |name, i|
194
- klass.define_method(name) { points[i] or Error.("No such point: #{name}") }
259
+ def define_point_shortcuts(klass, point_names)
260
+ point_names.each_with_index do |name, i|
261
+ klass.define_method(name) { points[i] or Error.("No such point: #{name}") }
262
+ end
195
263
  end
196
- end
197
264
 
198
- def self.define_shortcuts(klass, size, open:)
199
- point_names = SHORTCUTS.first([open ? size + 1 : size, SHORTCUTS.size].min)
200
- define_point_shortcuts(klass, point_names)
201
- define_line_shortcuts(klass, point_names, open:)
265
+ def define_shortcuts(klass, size, open:)
266
+ point_names = SHORTCUTS.first([open ? size + 1 : size, SHORTCUTS.size].min)
267
+ define_point_shortcuts(klass, point_names)
268
+ define_line_shortcuts(klass, point_names, closing: !open && point_names.size == size)
269
+ end
202
270
  end
203
271
 
204
- private_class_method :define_line_shortcuts, :define_point_shortcuts, :define_shortcuts
272
+ private_class_method :new
205
273
 
206
274
  # Creates a lined element from a geometry-definition block.
207
275
  # @yield evaluates point or segment definitions in the new element
208
276
  # @yieldreturn [Object] ignored block result
209
277
  # @return [void]
210
278
  # @raise [Sevgi::Geometry::Error] when the block is absent or defines inconsistent geometry
279
+ # @api private
211
280
  def initialize(&block)
212
281
  super()
213
282
 
@@ -220,18 +289,20 @@ module Sevgi
220
289
  freeze_geometry!
221
290
 
222
291
  sanitize
292
+ validate_geometry!
223
293
  end
224
294
 
225
295
  # Core methods
226
296
 
227
- # Returns an element with approximate points and segments.
297
+ # Returns an element rebuilt from rounded boundary points.
298
+ #
299
+ # Segments are derived from the rounded points so both representations describe the same path. When rounding
300
+ # breaks a concrete shape invariant, the result widens to a less specific Rect, Polygon, or Polyline rather than
301
+ # retaining a misleading concrete class.
302
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
228
303
  # @return [Sevgi::Geometry::Element::Lined]
229
- def approx
230
- points, segments = points(true), segments(true)
231
- self.class.send(:new) do
232
- @points, @segments = points, segments
233
- end
234
- end
304
+ # @raise [Sevgi::ArgumentError] when precision is invalid
305
+ def approx(precision = nil) = self.class.send(:approximate, *rounded_points(precision))
235
306
 
236
307
  # @overload draw(node, **attributes)
237
308
  # Draws an approximate element into a graphics node.
@@ -239,14 +310,14 @@ module Sevgi
239
310
  # @param attributes [Hash] drawing attributes
240
311
  # @return [Object] graphics node command result
241
312
  def draw(...)
242
- approx.draw!(...)
313
+ approx.send(:draw!, ...)
243
314
  end
244
315
 
245
316
  # Returns immutable element points.
246
317
  # @param approximate [Boolean] true to round points with the current function precision
247
318
  # @return [Array<Sevgi::Geometry::Point>] frozen point collection
248
319
  def points(approximate = false)
249
- approximate ? @points.map(&:approx).freeze : @points
320
+ approximate ? rounded_points(nil) : @points
250
321
  end
251
322
 
252
323
  # Returns the first point.
@@ -259,53 +330,64 @@ module Sevgi
259
330
  # @param approximate [Boolean] true to round segments with the current function precision
260
331
  # @return [Array<Sevgi::Geometry::Segment>] frozen segment collection
261
332
  def segments(approximate = false)
262
- approximate ? @segments.map(&:approx).freeze : @segments
333
+ approximate ? rounded_segments(nil) : @segments
263
334
  end
264
335
 
265
336
  # Affinity methods
266
337
 
338
+ # Affine operations preserve a shape class while its semantic invariant still holds. Axis-aligned Rect and
339
+ # Square instances widen to Rect or Parallelogram when rotation, skew, or unequal scaling changes that category.
340
+
267
341
  # @!parse
268
342
  # # Returns an element reflected across the selected axes.
269
343
  # # @param x [Boolean] reflect across the x-axis
270
344
  # # @param y [Boolean] reflect across the y-axis
271
345
  # # @return [Sevgi::Geometry::Element::Lined]
346
+ # # @raise [Sevgi::Geometry::Error] when a flag is not Boolean
272
347
  # def reflect(x: true, y: true); end
273
348
  #
274
349
  # # Returns an element rotated around the origin.
275
350
  # # @param a [Numeric] clockwise angle in degrees
276
351
  # # @return [Sevgi::Geometry::Element::Lined]
352
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
277
353
  # def rotate(a); end
278
354
  #
279
355
  # # Returns an element scaled from the origin.
280
356
  # # @param sx [Numeric] x scale factor
281
357
  # # @param sy [Numeric, Sevgi::Undefined] y scale factor, defaulting to sx
282
358
  # # @return [Sevgi::Geometry::Element::Lined]
359
+ # # @raise [Sevgi::Geometry::Error] when a scale is not a finite real number
283
360
  # def scale(sx, sy = Undefined); end
284
361
  #
285
362
  # # Returns an element skewed from the origin.
286
363
  # # @param ax [Numeric] x-axis skew angle in degrees
287
364
  # # @param ay [Numeric, Sevgi::Undefined] y-axis skew angle in degrees, defaulting to ax
288
365
  # # @return [Sevgi::Geometry::Element::Lined]
366
+ # # @raise [Sevgi::Geometry::Error] when an angle is not a finite real number
289
367
  # def skew(ax, ay = Undefined); end
290
368
  #
291
369
  # # Returns an element skewed along x.
292
370
  # # @param a [Numeric] skew angle in degrees
293
371
  # # @return [Sevgi::Geometry::Element::Lined]
372
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
294
373
  # def skew_x(a); end
295
374
  #
296
375
  # # Returns an element skewed along y.
297
376
  # # @param a [Numeric] skew angle in degrees
298
377
  # # @return [Sevgi::Geometry::Element::Lined]
378
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
299
379
  # def skew_y(a); end
300
380
  #
301
381
  # # Returns an element translated by offset.
302
382
  # # @param dx [Numeric] x offset
303
383
  # # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
304
384
  # # @return [Sevgi::Geometry::Element::Lined]
385
+ # # @raise [Sevgi::Geometry::Error] when an offset is not a finite real number
305
386
  # def translate(dx, dy = Undefined); end
306
- Geometry::Affinity.instance_methods.each do |transform|
387
+ Affinity.public_instance_methods(false).each do |transform|
307
388
  define_method(transform) do |*args, **kwargs, &block|
308
- self.class.new_by_points!(*points.map { it.public_send(transform, *args, **kwargs, &block) })
389
+ transformed = points.map { it.public_send(transform, *args, **kwargs, &block) }
390
+ self.class.send(:affine, *transformed)
309
391
  end
310
392
  end
311
393
 
@@ -340,16 +422,20 @@ module Sevgi
340
422
 
341
423
  # Intersects the element boundary with an equation.
342
424
  #
343
- # Boundary membership is tested on unrounded candidate points. `precision:`
344
- # only rounds returned coordinates and controls duplicate collapse after
345
- # membership has been accepted. When `precision` is nil, returned points use
346
- # the current function precision.
425
+ # Precision is applied consistently to boundary-membership tolerance, returned-coordinate rounding, and
426
+ # duplicate collapse. A nil precision uses the current thread's function precision for all three stages.
427
+ # @example Intersect a rectangle with a vertical line
428
+ # rect = Sevgi::Geometry::Rect[8, 4]
429
+ # axis = Sevgi::Geometry::Equation.vertical(3)
430
+ # rect.intersection(axis).map(&:deconstruct) # => [[3.0, 0.0], [3.0, 4.0]]
347
431
  # @param equation [Sevgi::Geometry::Equation] equation to intersect with
348
432
  # @param precision [Integer, nil] decimal precision for returned points, or nil for the current function default
349
433
  # @return [Array<Sevgi::Geometry::Point>] unique boundary intersection points
350
434
  # @raise [Sevgi::Geometry::Error] when equation is not an equation
351
435
  # @raise [Sevgi::PanicError] when the equation combination is not implemented
352
436
  def intersection(equation, precision: nil)
437
+ Error.("Must be an equation: #{equation}") unless equation.is_a?(Equation)
438
+
353
439
  points = equations.flat_map do |candidate|
354
440
  equation.intersect(candidate).select { |point| boundary_point?(point, precision) }
355
441
  end
@@ -363,7 +449,7 @@ module Sevgi
363
449
  # @param i [Integer] line index
364
450
  # @return [Sevgi::Geometry::Line]
365
451
  # @raise [Sevgi::Geometry::Error] when no line exists for index
366
- def [](i) = lines[i].tap { |line| Error.("No line exist for index: #{i}") unless line }
452
+ def [](i) = lines[i].tap { |line| Error.("No line exists for index: #{i}") unless line }
367
453
 
368
454
  # Returns the bounding rectangle.
369
455
  # @return [Sevgi::Geometry::Rect]
@@ -373,7 +459,7 @@ module Sevgi
373
459
  # @param i [Integer] point index
374
460
  # @return [Sevgi::Geometry::Point]
375
461
  # @raise [Sevgi::Geometry::Error] when no point exists for index
376
- def call(i) = points[i].tap { Error.("No point exist for index: #{i}") unless it }
462
+ def call(i) = points[i].tap { Error.("No point exists for index: #{i}") unless it }
377
463
 
378
464
  # Returns the first segment.
379
465
  # @return [Sevgi::Geometry::Segment]
@@ -390,9 +476,9 @@ module Sevgi
390
476
  .freeze
391
477
  end
392
478
 
393
- # Returns the sum of segment lengths.
479
+ # Returns the total path length.
394
480
  # @return [Float]
395
- def perimeter = @perimeter ||= segments.sum(&:length)
481
+ def length = @length ||= segments.sum(&:length)
396
482
 
397
483
  # Returns the last segment.
398
484
  # @return [Sevgi::Geometry::Segment]
@@ -404,13 +490,19 @@ module Sevgi
404
490
  #
405
491
  # Open paths have no filled interior; for them this predicate is true
406
492
  # only for points on the actual path boundary.
493
+ # @example Compare closed and open path containment
494
+ # rect = Sevgi::Geometry::Rect[8, 4]
495
+ # line = Sevgi::Geometry::Line.([0, 0], [8, 0])
496
+ # rect.inside?([4, 2]) # => true
497
+ # line.inside?([4, 2]) # => false
498
+ # line.inside?([4, 0]) # => true
407
499
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
408
500
  # @return [Boolean]
409
501
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
410
502
  def inside?(point)
411
503
  point = Tuple[Point, point]
412
504
 
413
- return on?(point) if self.class.open?
505
+ return on?(point) unless self.class.send(:close?)
414
506
 
415
507
  on?(point) || pnpoly(points, point)
416
508
  end
@@ -443,7 +535,7 @@ module Sevgi
443
535
  Error.("No segments found") unless segments
444
536
 
445
537
  [point = position, *segments.map { point = it.ending(point) }].tap do |points|
446
- # Perfectionist touch
538
+ # Share the first point object when the path closes within the current precision.
447
539
  points[-1] = points.first if points.first.eq?(points.last)
448
540
  end
449
541
  end
@@ -484,14 +576,28 @@ module Sevgi
484
576
  end
485
577
  # rubocop:enable Metrics/MethodLength
486
578
 
579
+ def rounded_points(precision)
580
+ rounded = @points.map { it.approx(precision) }
581
+ rounded[-1] = rounded.first if self.class.send(:close?)
582
+ rounded.freeze
583
+ end
584
+
585
+ def rounded_segments(precision)
586
+ rounded_points(precision).each_cons(2).map { Segment.(*it) }.freeze
587
+ end
588
+
487
589
  def sanitize
488
- np = self.class.poly? ? points.size : self.class.size + 1
590
+ np = self.class.send(:poly?) ? points.size : self.class.send(:size) + 1
489
591
  ns = np - 1
490
592
 
491
593
  Error.("Wrong number of points; expected #{np} where found #{points.size}") unless points.size == np
492
594
  Error.("Wrong number of segments; expected #{ns} where found #{segments.size}") unless segments.size == ns
493
- Error.("Element points must form a closed path") if self.class.close? && !points.first.eq?(points.last)
595
+ return unless self.class.send(:close?) && !points.first.eq?(points.last)
596
+
597
+ Error.("Element points must form a closed path")
494
598
  end
599
+
600
+ def validate_geometry! = nil
495
601
  end
496
602
 
497
603
  # Reserved base for future arced elements.
@@ -500,7 +606,6 @@ module Sevgi
500
606
  end
501
607
 
502
608
  private_constant :Arced
503
- # rubocop:enable Metrics/ClassLength
504
609
  end
505
610
 
506
611
  require_relative "elements/line"