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.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sevgi Geometry
2
2
 
3
- Small geometry primitives and operations for Sevgi drawings.
3
+ Sevgi Geometry provides the small set of geometric values and operations used by Sevgi drawings.
4
4
 
5
5
  ## Install
6
6
 
@@ -23,15 +23,15 @@ rect.box.width
23
23
 
24
24
  ## Ruby compatibility
25
25
 
26
- Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the current development Ruby from `.ruby-version`.
26
+ Requires Ruby 3.4.0 or newer. CI verifies the current Ruby 3.4 release and the development Ruby from `.ruby-version`.
27
27
 
28
28
  ## Native prerequisites
29
29
 
30
- None beyond Ruby and this gem's Ruby dependencies.
30
+ This gem needs only Ruby and its Ruby dependencies.
31
31
 
32
32
  ## Links
33
33
 
34
- - Documentation: https://sevgi.roktas.dev
35
- - API documentation: https://www.rubydoc.info/gems/sevgi-geometry
36
- - Source: https://github.com/roktas/sevgi/tree/main/geometry
37
- - Changelog: https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
34
+ - Documentation: <https://sevgi.roktas.dev>
35
+ - API documentation: <https://www.rubydoc.info/gems/sevgi-geometry>
36
+ - Source: <https://github.com/roktas/sevgi/tree/main/geometry>
37
+ - Changelog: <https://github.com/roktas/sevgi/blob/main/CHANGELOG.md>
@@ -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,121 +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
 
274
+ # Creates a lined element from a geometry-definition block.
275
+ # @yield evaluates point or segment definitions in the new element
276
+ # @yieldreturn [Object] ignored block result
277
+ # @return [void]
278
+ # @raise [Sevgi::Geometry::Error] when the block is absent or defines inconsistent geometry
279
+ # @api private
206
280
  def initialize(&block)
207
281
  super()
208
282
 
@@ -215,18 +289,20 @@ module Sevgi
215
289
  freeze_geometry!
216
290
 
217
291
  sanitize
292
+ validate_geometry!
218
293
  end
219
294
 
220
295
  # Core methods
221
296
 
222
- # 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
223
303
  # @return [Sevgi::Geometry::Element::Lined]
224
- def approx
225
- points, segments = points(true), segments(true)
226
- self.class.send(:new) do
227
- @points, @segments = points, segments
228
- end
229
- end
304
+ # @raise [Sevgi::ArgumentError] when precision is invalid
305
+ def approx(precision = nil) = self.class.send(:approximate, *rounded_points(precision))
230
306
 
231
307
  # @overload draw(node, **attributes)
232
308
  # Draws an approximate element into a graphics node.
@@ -234,14 +310,14 @@ module Sevgi
234
310
  # @param attributes [Hash] drawing attributes
235
311
  # @return [Object] graphics node command result
236
312
  def draw(...)
237
- approx.draw!(...)
313
+ approx.send(:draw!, ...)
238
314
  end
239
315
 
240
316
  # Returns immutable element points.
241
317
  # @param approximate [Boolean] true to round points with the current function precision
242
318
  # @return [Array<Sevgi::Geometry::Point>] frozen point collection
243
319
  def points(approximate = false)
244
- approximate ? @points.map(&:approx).freeze : @points
320
+ approximate ? rounded_points(nil) : @points
245
321
  end
246
322
 
247
323
  # Returns the first point.
@@ -254,53 +330,64 @@ module Sevgi
254
330
  # @param approximate [Boolean] true to round segments with the current function precision
255
331
  # @return [Array<Sevgi::Geometry::Segment>] frozen segment collection
256
332
  def segments(approximate = false)
257
- approximate ? @segments.map(&:approx).freeze : @segments
333
+ approximate ? rounded_segments(nil) : @segments
258
334
  end
259
335
 
260
336
  # Affinity methods
261
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
+
262
341
  # @!parse
263
342
  # # Returns an element reflected across the selected axes.
264
343
  # # @param x [Boolean] reflect across the x-axis
265
344
  # # @param y [Boolean] reflect across the y-axis
266
345
  # # @return [Sevgi::Geometry::Element::Lined]
346
+ # # @raise [Sevgi::Geometry::Error] when a flag is not Boolean
267
347
  # def reflect(x: true, y: true); end
268
348
  #
269
349
  # # Returns an element rotated around the origin.
270
350
  # # @param a [Numeric] clockwise angle in degrees
271
351
  # # @return [Sevgi::Geometry::Element::Lined]
352
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
272
353
  # def rotate(a); end
273
354
  #
274
355
  # # Returns an element scaled from the origin.
275
356
  # # @param sx [Numeric] x scale factor
276
357
  # # @param sy [Numeric, Sevgi::Undefined] y scale factor, defaulting to sx
277
358
  # # @return [Sevgi::Geometry::Element::Lined]
359
+ # # @raise [Sevgi::Geometry::Error] when a scale is not a finite real number
278
360
  # def scale(sx, sy = Undefined); end
279
361
  #
280
362
  # # Returns an element skewed from the origin.
281
363
  # # @param ax [Numeric] x-axis skew angle in degrees
282
364
  # # @param ay [Numeric, Sevgi::Undefined] y-axis skew angle in degrees, defaulting to ax
283
365
  # # @return [Sevgi::Geometry::Element::Lined]
366
+ # # @raise [Sevgi::Geometry::Error] when an angle is not a finite real number
284
367
  # def skew(ax, ay = Undefined); end
285
368
  #
286
369
  # # Returns an element skewed along x.
287
370
  # # @param a [Numeric] skew angle in degrees
288
371
  # # @return [Sevgi::Geometry::Element::Lined]
372
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
289
373
  # def skew_x(a); end
290
374
  #
291
375
  # # Returns an element skewed along y.
292
376
  # # @param a [Numeric] skew angle in degrees
293
377
  # # @return [Sevgi::Geometry::Element::Lined]
378
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
294
379
  # def skew_y(a); end
295
380
  #
296
381
  # # Returns an element translated by offset.
297
382
  # # @param dx [Numeric] x offset
298
383
  # # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
299
384
  # # @return [Sevgi::Geometry::Element::Lined]
385
+ # # @raise [Sevgi::Geometry::Error] when an offset is not a finite real number
300
386
  # def translate(dx, dy = Undefined); end
301
- Geometry::Affinity.instance_methods.each do |transform|
387
+ Affinity.public_instance_methods(false).each do |transform|
302
388
  define_method(transform) do |*args, **kwargs, &block|
303
- 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)
304
391
  end
305
392
  end
306
393
 
@@ -335,20 +422,25 @@ module Sevgi
335
422
 
336
423
  # Intersects the element boundary with an equation.
337
424
  #
338
- # Boundary membership is tested on unrounded candidate points. `precision:`
339
- # only rounds returned coordinates and controls duplicate collapse after
340
- # membership has been accepted. When `precision` is nil, returned points use
341
- # 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]]
342
431
  # @param equation [Sevgi::Geometry::Equation] equation to intersect with
343
432
  # @param precision [Integer, nil] decimal precision for returned points, or nil for the current function default
344
433
  # @return [Array<Sevgi::Geometry::Point>] unique boundary intersection points
345
434
  # @raise [Sevgi::Geometry::Error] when equation is not an equation
346
435
  # @raise [Sevgi::PanicError] when the equation combination is not implemented
347
436
  def intersection(equation, precision: nil)
348
- equations
349
- .flat_map { |candidate| equation.intersect(candidate).select { |point| on?(point) } }
350
- .map { |point| point.approx(precision) }
351
- .uniq
437
+ Error.("Must be an equation: #{equation}") unless equation.is_a?(Equation)
438
+
439
+ points = equations.flat_map do |candidate|
440
+ equation.intersect(candidate).select { |point| boundary_point?(point, precision) }
441
+ end
442
+
443
+ points.map { |point| point.approx(precision) }.uniq
352
444
  end
353
445
 
354
446
  # Properties
@@ -357,7 +449,7 @@ module Sevgi
357
449
  # @param i [Integer] line index
358
450
  # @return [Sevgi::Geometry::Line]
359
451
  # @raise [Sevgi::Geometry::Error] when no line exists for index
360
- 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 }
361
453
 
362
454
  # Returns the bounding rectangle.
363
455
  # @return [Sevgi::Geometry::Rect]
@@ -367,7 +459,7 @@ module Sevgi
367
459
  # @param i [Integer] point index
368
460
  # @return [Sevgi::Geometry::Point]
369
461
  # @raise [Sevgi::Geometry::Error] when no point exists for index
370
- 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 }
371
463
 
372
464
  # Returns the first segment.
373
465
  # @return [Sevgi::Geometry::Segment]
@@ -384,9 +476,9 @@ module Sevgi
384
476
  .freeze
385
477
  end
386
478
 
387
- # Returns the sum of segment lengths.
479
+ # Returns the total path length.
388
480
  # @return [Float]
389
- def perimeter = @perimeter ||= segments.sum(&:length)
481
+ def length = @length ||= segments.sum(&:length)
390
482
 
391
483
  # Returns the last segment.
392
484
  # @return [Sevgi::Geometry::Segment]
@@ -398,13 +490,19 @@ module Sevgi
398
490
  #
399
491
  # Open paths have no filled interior; for them this predicate is true
400
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
401
499
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
402
500
  # @return [Boolean]
403
501
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
404
502
  def inside?(point)
405
503
  point = Tuple[Point, point]
406
504
 
407
- return on?(point) if self.class.open?
505
+ return on?(point) unless self.class.send(:close?)
408
506
 
409
507
  on?(point) || pnpoly(points, point)
410
508
  end
@@ -427,11 +525,17 @@ module Sevgi
427
525
 
428
526
  private
429
527
 
528
+ def boundary_point?(point, precision)
529
+ return on?(point) if precision.nil?
530
+
531
+ F.with_precision(precision) { on?(point) }
532
+ end
533
+
430
534
  def calculate_points_from_segments
431
535
  Error.("No segments found") unless segments
432
536
 
433
537
  [point = position, *segments.map { point = it.ending(point) }].tap do |points|
434
- # Perfectionist touch
538
+ # Share the first point object when the path closes within the current precision.
435
539
  points[-1] = points.first if points.first.eq?(points.last)
436
540
  end
437
541
  end
@@ -472,14 +576,28 @@ module Sevgi
472
576
  end
473
577
  # rubocop:enable Metrics/MethodLength
474
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
+
475
589
  def sanitize
476
- np = self.class.poly? ? points.size : self.class.size + 1
590
+ np = self.class.send(:poly?) ? points.size : self.class.send(:size) + 1
477
591
  ns = np - 1
478
592
 
479
593
  Error.("Wrong number of points; expected #{np} where found #{points.size}") unless points.size == np
480
594
  Error.("Wrong number of segments; expected #{ns} where found #{segments.size}") unless segments.size == ns
481
- 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")
482
598
  end
599
+
600
+ def validate_geometry! = nil
483
601
  end
484
602
 
485
603
  # Reserved base for future arced elements.
@@ -488,7 +606,6 @@ module Sevgi
488
606
  end
489
607
 
490
608
  private_constant :Arced
491
- # rubocop:enable Metrics/ClassLength
492
609
  end
493
610
 
494
611
  require_relative "elements/line"