sevgi-geometry 0.95.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,35 +2,43 @@
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 also 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(...)
13
-
14
- # @overload arced(*args)
15
- # Builds an arced element subclass.
16
- # @api private
17
- # @param args [Array<Object>] arced factory arguments
18
- # @return [Class]
19
- # @raise [NoMethodError] until arced elements are implemented
20
- def self.arced(...) = Arced.build(...)
21
-
22
- private_class_method :arced
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, ...)
23
24
 
24
25
  # Core API
25
26
 
26
27
  # Returns a copy moved to a point and optional offset.
28
+ # @example Position a copy without mutating the source
29
+ # source = Sevgi::Geometry::Rect[8, 4, position: [1, 2]]
30
+ # moved = source.at([10, 20], dx: 2)
31
+ # source.position.deconstruct # => [1.0, 2.0]
32
+ # moved.position.deconstruct # => [12.0, 20.0]
27
33
  # @param point [Sevgi::Geometry::Point, Array<Numeric>, nil] target position, or nil to keep current position
28
34
  # @param dx [Numeric] additional x offset
29
35
  # @param dy [Numeric] additional y offset
30
36
  # @return [Sevgi::Geometry::Element] translated element
31
- # @raise [Sevgi::Geometry::Error] when point cannot be coerced
37
+ # @raise [Sevgi::Geometry::Error] when point or an offset cannot be coerced to finite geometry values
32
38
  def at(point = nil, dx: 0, dy: 0)
33
39
  point = point ? Tuple[Point, point] : position
40
+ dx = Real[:dx, dx]
41
+ dy = Real[:dy, dy]
34
42
 
35
43
  translate(
36
44
  (point.x - position.x) + dx,
@@ -44,7 +52,12 @@ module Sevgi
44
52
  # @raise [Sevgi::PanicError] when a subclass does not implement box
45
53
  def box = PanicError.("#{self.class}#box must be implemented")
46
54
 
47
- # Returns equations that define the element boundary.
55
+ # Reports whether the element boundary forms a closed path.
56
+ # @return [Boolean]
57
+ def closed? = self.class.send(:close?)
58
+
59
+ # Returns carrier equations for candidate boundary intersections.
60
+ # A finite element can represent only part of each carrier.
48
61
  # @abstract Subclasses implement element-specific equations.
49
62
  # @return [Array<Sevgi::Geometry::Equation>]
50
63
  # @raise [Sevgi::PanicError] when a subclass does not implement equations
@@ -55,6 +68,29 @@ module Sevgi
55
68
  # @return [Boolean]
56
69
  def ignorable?(precision: nil) = F.zero?(box.width, precision:) && F.zero?(box.height, precision:)
57
70
 
71
+ # Intersects the element boundary with an equation.
72
+ #
73
+ # Precision controls boundary membership, returned-coordinate rounding, and duplicate collapse.
74
+ # A nil precision uses the current thread's function precision for all three stages.
75
+ # @example Intersect a rectangle with a vertical line
76
+ # rect = Sevgi::Geometry::Rect[8, 4]
77
+ # axis = Sevgi::Geometry::Equation.vertical(3)
78
+ # rect.intersection(axis).map(&:deconstruct) # => [[3.0, 0.0], [3.0, 4.0]]
79
+ # @param equation [Sevgi::Geometry::Equation] equation to intersect with
80
+ # @param precision [Integer, nil] coordinate precision, or nil for the current function default
81
+ # @return [Array<Sevgi::Geometry::Point>] unique boundary intersection points
82
+ # @raise [Sevgi::Geometry::Error] when equation is not an equation
83
+ # @raise [Sevgi::PanicError] when the equation combination is not implemented
84
+ def intersection(equation, precision: nil)
85
+ Error.("Must be an equation: #{equation}") unless equation.is_a?(Equation)
86
+
87
+ points = equations.flat_map do |candidate|
88
+ equation.intersect(candidate).select { |point| boundary_point?(point, precision) }
89
+ end
90
+
91
+ points.map { |point| point.approx(precision) }.uniq
92
+ end
93
+
58
94
  # Returns the element position.
59
95
  # @abstract Subclasses implement element-specific positioning.
60
96
  # @return [Sevgi::Geometry::Point]
@@ -69,18 +105,54 @@ module Sevgi
69
105
  # @raise [Sevgi::PanicError] when a subclass does not implement translate
70
106
  def translate(_x, _y) = PanicError.("#{self.class}#translate must be implemented")
71
107
 
72
- # rubocop:disable Metrics/ClassLength
108
+ def boundary_point?(point, precision)
109
+ return on?(point) if precision.nil?
110
+
111
+ F.with_precision(precision) { on?(point) }
112
+ end
113
+
114
+ private :boundary_point?
115
+
73
116
  # Element whose boundary is represented by straight segments.
117
+ #
118
+ # The same path is available as immutable {#points}, {#segments}, and
119
+ # {#lines} collections. {#vertices} omits the repeated closing point from
120
+ # closed shapes. Open paths have the same values in `vertices` and `points`.
121
+ # Only closed shapes have a filled interior, so `inside?` on an open path
122
+ # is equivalent to testing its boundary.
123
+ # @example Inspect path and geometric views
124
+ # rect = Sevgi::Geometry::Rect[8, 4]
125
+ # rect.vertices.size # => 4
126
+ # rect.points.size # => 5
127
+ # rect.segments.size # => 4
128
+ # rect.lines.size # => 4
129
+ # @see Sevgi::Geometry::Operation.sweep
74
130
  class Lined < self
75
131
  # Open lined element base class.
132
+ # @api private
76
133
  Open = Class.new(self) do
134
+ # Returns the first point in the directed path.
135
+ # @return [Sevgi::Geometry::Point]
136
+ def starting = points.first
137
+
138
+ # Returns the last point in the directed path.
139
+ # @return [Sevgi::Geometry::Point]
140
+ def ending = points.last
141
+
142
+ # Returns the same trace with opposite traversal.
143
+ # @return [Sevgi::Geometry::Element::Lined]
144
+ def reverse = self.class.send(:new_by_points!, *points.reverse)
145
+
77
146
  # Draws the element as an SVG polyline.
78
147
  # @param node [Object] graphics node receiving the drawing command
79
148
  # @return [Object] graphics node command result
80
149
  def draw!(node, **) = node.polyline(points: points.map { it.deconstruct.join(",") }, **)
150
+
151
+ private :draw!
81
152
  end
82
153
 
83
154
  # Closed lined element base class.
155
+ # @api private
84
156
  Close = Class.new(self) do
85
157
  # Creates a closed element from points, appending the first point.
86
158
  # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
@@ -88,126 +160,163 @@ module Sevgi
88
160
  # @raise [Sevgi::Geometry::Error] when any point cannot be coerced
89
161
  def self.new_by_points(*points) = super(*points, points.first)
90
162
 
163
+ # Returns the closed path perimeter.
164
+ # @return [Float]
165
+ def perimeter = length
166
+
91
167
  # Draws the element as an SVG polygon.
92
168
  # @param node [Object] graphics node receiving the drawing command
93
169
  # @return [Object] graphics node command result
94
170
  def draw!(node, **) = node.polygon(points: points.map { it.deconstruct.join(",") }, **)
171
+
172
+ private :draw!
173
+
174
+ private_class_method :new_by_points
95
175
  end
96
176
 
97
177
  # Class methods
98
178
 
99
179
  # Point shortcut names generated for fixed-size lined elements.
180
+ # @api private
100
181
  SHORTCUTS = ("A".."Z").to_a.freeze
182
+ private_constant :Close, :Open, :SHORTCUTS
183
+
184
+ class << self
185
+ private
186
+
187
+ # Builds a concrete lined element class.
188
+ # @param size [Integer, Sevgi::Undefined] segment count for fixed-size elements, or Undefined for variable size
189
+ # @param open [Boolean] true for an open path, false for a closed path
190
+ # @return [Class] lined element subclass
191
+ # @raise [Sevgi::Geometry::Error] when size is not Undefined or a positive Integer, or open is not Boolean
192
+ # @api private
193
+ def build(size = Undefined, open: false)
194
+ validate_factory(size, open)
195
+
196
+ klass = Class.new(open ? Open : Close)
197
+ klass.define_singleton_method(:close?) { !open }
198
+ klass.define_singleton_method(:poly?) { size.equal?(Undefined) }
199
+ klass.define_singleton_method(:size) { size }
200
+ define_shortcuts(klass, size, open:) unless size.equal?(Undefined)
201
+ klass.public_class_method(:[], :call, :from_points, :from_segments)
202
+ klass.private_class_method(:close?, :poly?, :size)
203
+ klass
204
+ end
101
205
 
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 }
206
+ def validate_factory(size, open)
207
+ unless size.equal?(Undefined) || (size.is_a?(::Integer) && size.positive?)
208
+ Error.("Lined segment count must be a positive Integer or Undefined")
209
+ end
115
210
 
116
- Lined.send(:define_shortcuts, self, size, open:) unless size == Undefined
211
+ Error.("Lined open flag must be Boolean") unless open.equal?(true) || open.equal?(false)
117
212
  end
118
- end
119
213
 
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]
214
+ # @overload [](*segments, position: Origin)
215
+ # Builds an element from segments.
216
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
217
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
218
+ # @return [Sevgi::Geometry::Element::Lined]
219
+ # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
220
+ # @api private
221
+ def [](...) = new_by_segments(...)
222
+
223
+ # @overload call(*points)
224
+ # Builds an element from points.
225
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
226
+ # @return [Sevgi::Geometry::Element::Lined]
227
+ # @raise [Sevgi::Geometry::Error] when points cannot be coerced
228
+ # @api private
229
+ def call(...) = new_by_points(...)
230
+
231
+ # @overload from_points(*points)
232
+ # Builds an element from points.
233
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
234
+ # @return [Sevgi::Geometry::Element::Lined]
235
+ # @raise [Sevgi::Geometry::Error] when points cannot be coerced
236
+ # @api private
237
+ def from_points(...) = call(...)
238
+
239
+ # @overload from_segments(*segments, position: Origin)
240
+ # Builds an element from segments.
241
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
242
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
243
+ # @return [Sevgi::Geometry::Element::Lined]
244
+ # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
245
+ # @api private
246
+ def from_segments(*segments, position: Origin) = self[*segments, position:]
247
+
248
+ def affine(*points) = new_by_points!(*points)
249
+
250
+ def approximate(*points)
251
+ new_by_points!(*points)
252
+ rescue Error
253
+ (close? ? Polygon : Polyline).send(:new_by_points!, *points)
166
254
  end
167
- end
168
255
 
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]
256
+ # @overload new_by_points(*points)
257
+ # Builds an element from points, applying closed-path behavior where appropriate.
258
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
259
+ # @return [Sevgi::Geometry::Element::Lined]
260
+ # @raise [Sevgi::Geometry::Error] when points cannot be coerced
261
+ # @api private
262
+ def new_by_points(...) = new_by_points!(...)
263
+
264
+ # Builds an element from an exact point path.
265
+ #
266
+ # Closed classes require the closing point to be supplied by the caller.
267
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] exact boundary points
268
+ # @return [Sevgi::Geometry::Element::Lined]
269
+ # @raise [Sevgi::Geometry::Error] when points cannot be coerced or do not satisfy the class path contract
270
+ # @api private
271
+ def new_by_points!(*points)
272
+ new do
273
+ @points = Tuples[Point, *points]
274
+ end
178
275
  end
179
- end
180
276
 
181
- private_class_method(:new)
277
+ # Builds an element from segments and a start position.
278
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
279
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
280
+ # @return [Sevgi::Geometry::Element::Lined]
281
+ # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
282
+ # @api private
283
+ def new_by_segments(*segments, position: Origin)
284
+ new do
285
+ @position = Tuple[Point, position]
286
+ @segments = Tuples[Segment, *segments]
287
+ end
288
+ end
182
289
 
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?
290
+ def define_line_shortcuts(klass, point_names, closing:)
291
+ line_names = point_names.each_cons(2).map(&:join)
292
+ line_names << "#{point_names.last}#{point_names.first}" if closing
186
293
 
187
- line_names.each_with_index do |name, i|
188
- klass.define_method(name) { lines[i] or Error.("No such line: #{name}") }
294
+ line_names.each_with_index do |name, i|
295
+ klass.define_method(name) { lines[i] or Error.("No such line: #{name}") }
296
+ end
189
297
  end
190
- end
191
298
 
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}") }
299
+ def define_point_shortcuts(klass, point_names)
300
+ point_names.each_with_index do |name, i|
301
+ klass.define_method(name) { points[i] or Error.("No such point: #{name}") }
302
+ end
195
303
  end
196
- end
197
304
 
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:)
305
+ def define_shortcuts(klass, size, open:)
306
+ point_names = SHORTCUTS.first([open ? size + 1 : size, SHORTCUTS.size].min)
307
+ define_point_shortcuts(klass, point_names)
308
+ define_line_shortcuts(klass, point_names, closing: !open && point_names.size == size)
309
+ end
202
310
  end
203
311
 
204
- private_class_method :define_line_shortcuts, :define_point_shortcuts, :define_shortcuts
312
+ private_class_method :new
205
313
 
206
314
  # Creates a lined element from a geometry-definition block.
207
315
  # @yield evaluates point or segment definitions in the new element
208
316
  # @yieldreturn [Object] ignored block result
209
317
  # @return [void]
210
318
  # @raise [Sevgi::Geometry::Error] when the block is absent or defines inconsistent geometry
319
+ # @api private
211
320
  def initialize(&block)
212
321
  super()
213
322
 
@@ -220,18 +329,20 @@ module Sevgi
220
329
  freeze_geometry!
221
330
 
222
331
  sanitize
332
+ validate_geometry!
223
333
  end
224
334
 
225
335
  # Core methods
226
336
 
227
- # Returns an element with approximate points and segments.
337
+ # Returns an element rebuilt from rounded boundary points.
338
+ #
339
+ # Segments are derived from the rounded points so both representations describe the same path. When rounding
340
+ # breaks a concrete shape invariant, the result widens to a less specific Rect, Polygon, or Polyline rather than
341
+ # retaining a misleading concrete class.
342
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
228
343
  # @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
344
+ # @raise [Sevgi::ArgumentError] when precision is invalid
345
+ def approx(precision = nil) = self.class.send(:approximate, *rounded_points(precision))
235
346
 
236
347
  # @overload draw(node, **attributes)
237
348
  # Draws an approximate element into a graphics node.
@@ -239,14 +350,24 @@ module Sevgi
239
350
  # @param attributes [Hash] drawing attributes
240
351
  # @return [Object] graphics node command result
241
352
  def draw(...)
242
- approx.draw!(...)
353
+ approx.send(:draw!, ...)
243
354
  end
244
355
 
245
- # Returns immutable element points.
356
+ # Returns immutable element points in path order.
357
+ # Closed elements repeat the first vertex at the end.
246
358
  # @param approximate [Boolean] true to round points with the current function precision
247
359
  # @return [Array<Sevgi::Geometry::Point>] frozen point collection
248
360
  def points(approximate = false)
249
- approximate ? @points.map(&:approx).freeze : @points
361
+ approximate ? rounded_points(nil) : @points
362
+ end
363
+
364
+ # Returns immutable geometric vertices in path order.
365
+ # Closed elements omit the repeated closing point. Open elements return {#points}.
366
+ # @return [Array<Sevgi::Geometry::Point>] frozen vertex collection
367
+ def vertices
368
+ return points unless closed?
369
+
370
+ @vertices ||= points[...-1].freeze
250
371
  end
251
372
 
252
373
  # Returns the first point.
@@ -259,53 +380,64 @@ module Sevgi
259
380
  # @param approximate [Boolean] true to round segments with the current function precision
260
381
  # @return [Array<Sevgi::Geometry::Segment>] frozen segment collection
261
382
  def segments(approximate = false)
262
- approximate ? @segments.map(&:approx).freeze : @segments
383
+ approximate ? rounded_segments(nil) : @segments
263
384
  end
264
385
 
265
386
  # Affinity methods
266
387
 
388
+ # Affine operations preserve a shape class while its semantic invariant still holds. Axis-aligned Rect and
389
+ # Square instances widen to Rect or Parallelogram when rotation, skew, or unequal scaling changes that category.
390
+
267
391
  # @!parse
268
392
  # # Returns an element reflected across the selected axes.
269
393
  # # @param x [Boolean] reflect across the x-axis
270
394
  # # @param y [Boolean] reflect across the y-axis
271
395
  # # @return [Sevgi::Geometry::Element::Lined]
396
+ # # @raise [Sevgi::Geometry::Error] when a flag is not Boolean
272
397
  # def reflect(x: true, y: true); end
273
398
  #
274
399
  # # Returns an element rotated around the origin.
275
400
  # # @param a [Numeric] clockwise angle in degrees
276
401
  # # @return [Sevgi::Geometry::Element::Lined]
402
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
277
403
  # def rotate(a); end
278
404
  #
279
405
  # # Returns an element scaled from the origin.
280
406
  # # @param sx [Numeric] x scale factor
281
407
  # # @param sy [Numeric, Sevgi::Undefined] y scale factor, defaulting to sx
282
408
  # # @return [Sevgi::Geometry::Element::Lined]
409
+ # # @raise [Sevgi::Geometry::Error] when a scale is not a finite real number
283
410
  # def scale(sx, sy = Undefined); end
284
411
  #
285
412
  # # Returns an element skewed from the origin.
286
413
  # # @param ax [Numeric] x-axis skew angle in degrees
287
414
  # # @param ay [Numeric, Sevgi::Undefined] y-axis skew angle in degrees, defaulting to ax
288
415
  # # @return [Sevgi::Geometry::Element::Lined]
416
+ # # @raise [Sevgi::Geometry::Error] when an angle is not a finite real number
289
417
  # def skew(ax, ay = Undefined); end
290
418
  #
291
419
  # # Returns an element skewed along x.
292
420
  # # @param a [Numeric] skew angle in degrees
293
421
  # # @return [Sevgi::Geometry::Element::Lined]
422
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
294
423
  # def skew_x(a); end
295
424
  #
296
425
  # # Returns an element skewed along y.
297
426
  # # @param a [Numeric] skew angle in degrees
298
427
  # # @return [Sevgi::Geometry::Element::Lined]
428
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
299
429
  # def skew_y(a); end
300
430
  #
301
431
  # # Returns an element translated by offset.
302
432
  # # @param dx [Numeric] x offset
303
433
  # # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
304
434
  # # @return [Sevgi::Geometry::Element::Lined]
435
+ # # @raise [Sevgi::Geometry::Error] when an offset is not a finite real number
305
436
  # def translate(dx, dy = Undefined); end
306
- Geometry::Affinity.instance_methods.each do |transform|
437
+ Affinity.public_instance_methods(false).each do |transform|
307
438
  define_method(transform) do |*args, **kwargs, &block|
308
- self.class.new_by_points!(*points.map { it.public_send(transform, *args, **kwargs, &block) })
439
+ transformed = points.map { it.public_send(transform, *args, **kwargs, &block) }
440
+ self.class.send(:affine, *transformed)
309
441
  end
310
442
  end
311
443
 
@@ -338,32 +470,13 @@ module Sevgi
338
470
  # @return [Array<Sevgi::Geometry::Equation::Linear>] frozen equation collection
339
471
  def equations = @equations ||= lines.map(&:equation).freeze
340
472
 
341
- # Intersects the element boundary with an equation.
342
- #
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.
347
- # @param equation [Sevgi::Geometry::Equation] equation to intersect with
348
- # @param precision [Integer, nil] decimal precision for returned points, or nil for the current function default
349
- # @return [Array<Sevgi::Geometry::Point>] unique boundary intersection points
350
- # @raise [Sevgi::Geometry::Error] when equation is not an equation
351
- # @raise [Sevgi::PanicError] when the equation combination is not implemented
352
- def intersection(equation, precision: nil)
353
- points = equations.flat_map do |candidate|
354
- equation.intersect(candidate).select { |point| boundary_point?(point, precision) }
355
- end
356
-
357
- points.map { |point| point.approx(precision) }.uniq
358
- end
359
-
360
473
  # Properties
361
474
 
362
475
  # Returns a line by index.
363
476
  # @param i [Integer] line index
364
477
  # @return [Sevgi::Geometry::Line]
365
478
  # @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 }
479
+ def [](i) = lines[i].tap { |line| Error.("No line exists for index: #{i}") unless line }
367
480
 
368
481
  # Returns the bounding rectangle.
369
482
  # @return [Sevgi::Geometry::Rect]
@@ -373,26 +486,31 @@ module Sevgi
373
486
  # @param i [Integer] point index
374
487
  # @return [Sevgi::Geometry::Point]
375
488
  # @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 }
489
+ def call(i) = points[i].tap { Error.("No point exists for index: #{i}") unless it }
377
490
 
378
491
  # Returns the first segment.
379
492
  # @return [Sevgi::Geometry::Segment]
380
493
  def head = @head ||= segments.first
381
494
 
382
- # Returns immutable boundary lines derived from segments and points.
495
+ # Returns immutable boundary lines with the stored endpoints and segments.
383
496
  # @return [Array<Sevgi::Geometry::Line>] frozen line collection
384
497
  def lines
385
- @lines ||= segments
386
- .zip(points[...segments.size])
387
- .map { |segment, position|
388
- segment.line(position)
389
- }
498
+ @lines ||= points
499
+ .each_cons(2)
500
+ .zip(segments)
501
+ .map do |points, segment|
502
+ # Rebuilding from either view alone loses stored endpoints or input angles.
503
+ Line.send(:new) do
504
+ @points = points
505
+ @segments = [segment]
506
+ end
507
+ end
390
508
  .freeze
391
509
  end
392
510
 
393
- # Returns the sum of segment lengths.
511
+ # Returns the total path length.
394
512
  # @return [Float]
395
- def perimeter = @perimeter ||= segments.sum(&:length)
513
+ def length = @length ||= segments.sum(&:length)
396
514
 
397
515
  # Returns the last segment.
398
516
  # @return [Sevgi::Geometry::Segment]
@@ -402,15 +520,21 @@ module Sevgi
402
520
 
403
521
  # Reports whether a point is inside or on the boundary.
404
522
  #
405
- # Open paths have no filled interior; for them this predicate is true
523
+ # Open paths have no filled interior. For them this predicate is true
406
524
  # only for points on the actual path boundary.
525
+ # @example Compare closed and open path containment
526
+ # rect = Sevgi::Geometry::Rect[8, 4]
527
+ # line = Sevgi::Geometry::Line.([0, 0], [8, 0])
528
+ # rect.inside?([4, 2]) # => true
529
+ # line.inside?([4, 2]) # => false
530
+ # line.inside?([4, 0]) # => true
407
531
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
408
532
  # @return [Boolean]
409
533
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
410
534
  def inside?(point)
411
535
  point = Tuple[Point, point]
412
536
 
413
- return on?(point) if self.class.open?
537
+ return on?(point) unless closed?
414
538
 
415
539
  on?(point) || pnpoly(points, point)
416
540
  end
@@ -433,18 +557,11 @@ module Sevgi
433
557
 
434
558
  private
435
559
 
436
- def boundary_point?(point, precision)
437
- return on?(point) if precision.nil?
438
-
439
- F.with_precision(precision) { on?(point) }
440
- end
441
-
442
560
  def calculate_points_from_segments
443
561
  Error.("No segments found") unless segments
444
562
 
445
563
  [point = position, *segments.map { point = it.ending(point) }].tap do |points|
446
- # Perfectionist touch
447
- points[-1] = points.first if points.first.eq?(points.last)
564
+ points[-1] = points.first if closed? && points.first.eq?(points.last)
448
565
  end
449
566
  end
450
567
 
@@ -484,23 +601,62 @@ module Sevgi
484
601
  end
485
602
  # rubocop:enable Metrics/MethodLength
486
603
 
604
+ def rounded_points(precision)
605
+ rounded = @points.map { it.approx(precision) }
606
+ rounded[-1] = rounded.first if closed?
607
+ rounded.freeze
608
+ end
609
+
610
+ def rounded_segments(precision)
611
+ rounded_points(precision).each_cons(2).map { Segment.(*it) }.freeze
612
+ end
613
+
487
614
  def sanitize
488
- np = self.class.poly? ? points.size : self.class.size + 1
615
+ np = self.class.send(:poly?) ? points.size : self.class.send(:size) + 1
489
616
  ns = np - 1
490
617
 
491
618
  Error.("Wrong number of points; expected #{np} where found #{points.size}") unless points.size == np
492
619
  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)
620
+ return unless closed? && !points.first.eq?(points.last)
621
+
622
+ Error.("Element points must form a closed path")
494
623
  end
624
+
625
+ def validate_geometry! = nil
495
626
  end
496
627
 
497
- # Reserved base for future arced elements.
498
- # @api private
628
+ # Abstract family of circular and elliptical boundary elements.
629
+ # Arc is open, while Ellipse and Circle have closed boundaries. Concrete values share exact and precision-aware comparisons.
499
630
  class Arced < self
631
+ # Compares canonical fields with coordinate and numeric precision.
632
+ # @param other [Object] comparison target
633
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
634
+ # @return [Boolean]
635
+ def eq?(other, precision: nil)
636
+ other.instance_of?(self.class) &&
637
+ state.zip(other.send(:state)).all? do |left, right|
638
+ left.is_a?(::Numeric) ? F.eq?(left, right, precision:) : left.eq?(right, precision:)
639
+ end
640
+ end
641
+
642
+ # Reports strict equality by concrete class and canonical fields.
643
+ # @param other [Object] comparison target
644
+ # @return [Boolean]
645
+ def eql?(other) = other.instance_of?(self.class) && state == other.send(:state)
646
+
647
+ # Returns a hash independent of numeric precision.
648
+ # @return [Integer]
649
+ def hash = [self.class, *state].hash
650
+
651
+ # Reports whether a point is outside the element.
652
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
653
+ # @return [Boolean]
654
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
655
+ def outside?(point) = !inside?(point)
656
+
657
+ alias == eql?
500
658
  end
501
659
 
502
- private_constant :Arced
503
- # rubocop:enable Metrics/ClassLength
504
660
  end
505
661
 
506
662
  require_relative "elements/line"
@@ -509,5 +665,8 @@ module Sevgi
509
665
  require_relative "elements/polyline"
510
666
  require_relative "elements/rect"
511
667
  require_relative "elements/triangle"
668
+ require_relative "elements/ellipse"
669
+ require_relative "elements/circle"
670
+ require_relative "elements/arc"
512
671
  end
513
672
  end