sevgi-geometry 0.73.2 → 0.94.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9be3893bcfca34cd9d7a2014de7fbb21ba0720e34625c700dcd9dd20b16f1c52
4
- data.tar.gz: d9936cd27f1a5360163ef50ca926263b7d913bf3512b14006a504dd5c393d3aa
3
+ metadata.gz: d61732219fd2a5aec56e466123a5aab75df9e1b25734fe8449a2d4235b8d10b4
4
+ data.tar.gz: a0ba1edbfaefbcc54912f5fe0e24cea5af13ec834fde5e3147c81fe4ae3a80c2
5
5
  SHA512:
6
- metadata.gz: 452bdaf29a65ce32032326de0968852cb1449e4faefb5fe3ee24fe6cb8e7ea335d5760264fa08505c6a83f6f763585d9fd4c859b278c6d008ba00be58c7e4b18
7
- data.tar.gz: 50f163f2a6d50e2bea7be781a45bba8a1dbdaaaf45a00004b73d8c58ac2306da54cf82a6418696fa11fa49ac47f562857f4b56a81007f0a73244cb9b67079713
6
+ metadata.gz: cfdf1b5a5e128e678225357e9073282dfc8376ed0547f9dadb00bad1d7c60243b748077912a3b218226a079ba5605ab5c5d9a6f7abe0d0541132dcc0aeca2328
7
+ data.tar.gz: 37aa84553b62b5d8316923fdfb258de010cafbed34ea3a464a7e5d3134f746acd18e3bb39985a613b64a731d2195246aab755c545345d812a14d141bb8fd2625
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ Sevgi Geometry follows the root Sevgi release notes:
4
+ https://github.com/roktas/sevgi/blob/main/CHANGELOG.md
data/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Sevgi Geometry is distributed under the GNU General Public License v3.0 or later.
2
+
3
+ SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ Full project license: https://github.com/roktas/sevgi/blob/main/LICENSE
data/README.md CHANGED
@@ -0,0 +1,37 @@
1
+ # Sevgi Geometry
2
+
3
+ Small geometry primitives and operations for Sevgi drawings.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install sevgi-geometry
9
+ ```
10
+
11
+ ## Require
12
+
13
+ ```ruby
14
+ require "sevgi/geometry"
15
+ ```
16
+
17
+ ## Example
18
+
19
+ ```ruby
20
+ rect = Sevgi::Geometry::Rect[3, 5]
21
+ rect.box.width
22
+ ```
23
+
24
+ ## Ruby compatibility
25
+
26
+ Requires Ruby 3.4.0 or newer. CI verifies Ruby 3.4 and the current development Ruby from `.ruby-version`.
27
+
28
+ ## Native prerequisites
29
+
30
+ None beyond Ruby and this gem's Ruby dependencies.
31
+
32
+ ## Links
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
@@ -2,47 +2,107 @@
2
2
 
3
3
  module Sevgi
4
4
  module Geometry
5
+ # Base class for geometric elements.
5
6
  class Element
7
+ # @overload lined(size = Undefined, open: false)
8
+ # Builds a lined element subclass.
9
+ # @param size [Integer, Sevgi::Undefined] segment count for fixed-size elements, or Undefined for variable size
10
+ # @param open [Boolean] true for an open path, false for a closed path
11
+ # @return [Class] subclass of {Sevgi::Geometry::Element::Lined}
6
12
  def self.lined(...) = Lined.build(...)
7
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
8
20
  def self.arced(...) = Arced.build(...)
9
21
 
22
+ private_class_method :arced
23
+
10
24
  # Core API
11
25
 
26
+ # Returns a copy moved to a point and optional offset.
27
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>, nil] target position, or nil to keep current position
28
+ # @param dx [Numeric] additional x offset
29
+ # @param dy [Numeric] additional y offset
30
+ # @return [Sevgi::Geometry::Element] translated element
31
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
12
32
  def at(point = nil, dx: 0, dy: 0)
33
+ point = point ? Tuple[Point, point] : position
34
+
13
35
  translate(
14
- ((point ||= position).x - position.x) + dx,
36
+ (point.x - position.x) + dx,
15
37
  (point.y - position.y) + dy
16
38
  )
17
39
  end
18
40
 
19
- def box = raise NoMethodError, "#{self.class}#box must be implemented"
20
-
21
- def equations = raise NoMethodError, "#{self.class}#equations must be implemented"
22
-
41
+ # Returns the bounding rectangle.
42
+ # @abstract Subclasses implement element-specific bounds.
43
+ # @return [Sevgi::Geometry::Rect]
44
+ # @raise [Sevgi::PanicError] when a subclass does not implement box
45
+ def box = PanicError.("#{self.class}#box must be implemented")
46
+
47
+ # Returns equations that define the element boundary.
48
+ # @abstract Subclasses implement element-specific equations.
49
+ # @return [Array<Sevgi::Geometry::Equation>]
50
+ # @raise [Sevgi::PanicError] when a subclass does not implement equations
51
+ def equations = PanicError.("#{self.class}#equations must be implemented")
52
+
53
+ # Reports whether the element has zero bounding width and height.
54
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
55
+ # @return [Boolean]
23
56
  def ignorable?(precision: nil) = F.zero?(box.width, precision:) && F.zero?(box.height, precision:)
24
57
 
25
- def position = raise NoMethodError, "#{self.class}#position must be implemented"
58
+ # Returns the element position.
59
+ # @abstract Subclasses implement element-specific positioning.
60
+ # @return [Sevgi::Geometry::Point]
61
+ # @raise [Sevgi::PanicError] when a subclass does not implement position
62
+ def position = PanicError.("#{self.class}#position must be implemented")
26
63
 
27
- def translate(_x, _y) = raise NoMethodError, "#{self.class}#translate must be implemented"
64
+ # Returns a translated copy.
65
+ # @abstract Subclasses implement element-specific translation.
66
+ # @param _x [Numeric] x offset
67
+ # @param _y [Numeric] y offset
68
+ # @return [Sevgi::Geometry::Element]
69
+ # @raise [Sevgi::PanicError] when a subclass does not implement translate
70
+ def translate(_x, _y) = PanicError.("#{self.class}#translate must be implemented")
28
71
 
29
72
  # rubocop:disable Metrics/ClassLength
73
+ # Element whose boundary is represented by straight segments.
30
74
  class Lined < self
75
+ # Open lined element base class.
31
76
  Open = Class.new(self) do
77
+ # Draws the element as an SVG polyline.
78
+ # @param node [Object] graphics node receiving the drawing command
79
+ # @return [Object] graphics node command result
32
80
  def draw!(node, **) = node.polyline(points: points.map { it.deconstruct.join(",") }, **)
33
81
  end
34
82
 
83
+ # Closed lined element base class.
35
84
  Close = Class.new(self) do
85
+ # Creates a closed element from points, appending the first point.
86
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
87
+ # @return [Sevgi::Geometry::Element::Lined]
88
+ # @raise [Sevgi::Geometry::Error] when any point cannot be coerced
36
89
  def self.new_by_points(*points) = super(*points, points.first)
37
90
 
91
+ # Draws the element as an SVG polygon.
92
+ # @param node [Object] graphics node receiving the drawing command
93
+ # @return [Object] graphics node command result
38
94
  def draw!(node, **) = node.polygon(points: points.map { it.deconstruct.join(",") }, **)
39
95
  end
40
96
 
41
97
  # Class methods
42
98
 
99
+ # Point shortcut names generated for fixed-size lined elements.
43
100
  SHORTCUTS = ("A".."Z").to_a.freeze
44
101
 
45
- # rubocop:disable Metrics/MethodLength
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
46
106
  def self.build(size = Undefined, open: false)
47
107
  Class.new(open ? Open : Close) do
48
108
  define_singleton_method(:close?) { !open }
@@ -53,33 +113,64 @@ module Sevgi
53
113
 
54
114
  define_singleton_method(:size) { size }
55
115
 
56
- unless size == Undefined
57
- SHORTCUTS[..size.clamp(..SHORTCUTS.size)].each_with_index do |name, i|
58
- define_method(name) { points[i] or Error.("No such point: #{name}") }
59
- end
60
-
61
- methods = SHORTCUTS[..size.clamp(..SHORTCUTS.size)].each_cons(2).with_index.map do |names, i|
62
- define_method(name = names.join) { lines[i] or Error.("No such line: #{name}") }
63
- end
64
-
65
- alias_method("#{SHORTCUTS[size - 1]}#{SHORTCUTS.first}", methods.last)
66
- end
116
+ Lined.send(:define_shortcuts, self, size, open:) unless size == Undefined
67
117
  end
68
- # rubocop:enable Metrics/MethodLength
69
118
  end
70
119
 
71
- def self.[](...) = new_by_segments(...)
72
-
73
- def self.call(...) = new_by_points(...)
74
-
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
75
155
  def self.new_by_points(...) = new_by_points!(...)
76
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
77
163
  def self.new_by_points!(*points)
78
164
  new do
79
165
  @points = Tuples[Point, *points]
80
166
  end
81
167
  end
82
168
 
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
83
174
  def self.new_by_segments(*segments, position: Origin)
84
175
  new do
85
176
  @position = Tuple[Point, position]
@@ -89,6 +180,29 @@ module Sevgi
89
180
 
90
181
  private_class_method(:new)
91
182
 
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?
186
+
187
+ line_names.each_with_index do |name, i|
188
+ klass.define_method(name) { lines[i] or Error.("No such line: #{name}") }
189
+ end
190
+ end
191
+
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}") }
195
+ end
196
+ end
197
+
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:)
202
+ end
203
+
204
+ private_class_method :define_line_shortcuts, :define_point_shortcuts, :define_shortcuts
205
+
92
206
  def initialize(&block)
93
207
  super()
94
208
 
@@ -98,12 +212,15 @@ module Sevgi
98
212
 
99
213
  @points ||= calculate_points_from_segments
100
214
  @segments ||= calculate_segments_from_points
215
+ freeze_geometry!
101
216
 
102
217
  sanitize
103
218
  end
104
219
 
105
220
  # Core methods
106
221
 
222
+ # Returns an element with approximate points and segments.
223
+ # @return [Sevgi::Geometry::Element::Lined]
107
224
  def approx
108
225
  points, segments = points(true), segments(true)
109
226
  self.class.send(:new) do
@@ -111,24 +228,76 @@ module Sevgi
111
228
  end
112
229
  end
113
230
 
231
+ # @overload draw(node, **attributes)
232
+ # Draws an approximate element into a graphics node.
233
+ # @param node [Object] graphics node receiving the drawing command
234
+ # @param attributes [Hash] drawing attributes
235
+ # @return [Object] graphics node command result
114
236
  def draw(...)
115
237
  approx.draw!(...)
116
238
  end
117
239
 
240
+ # Returns immutable element points.
241
+ # @param approximate [Boolean] true to round points with the current function precision
242
+ # @return [Array<Sevgi::Geometry::Point>] frozen point collection
118
243
  def points(approximate = false)
119
- approximate ? (@points_approx ||= @points.map(&:approx)) : @points
244
+ approximate ? @points.map(&:approx).freeze : @points
120
245
  end
121
246
 
247
+ # Returns the first point.
248
+ # @return [Sevgi::Geometry::Point]
122
249
  def position
123
250
  @position ||= points.first
124
251
  end
125
252
 
253
+ # Returns immutable element segments.
254
+ # @param approximate [Boolean] true to round segments with the current function precision
255
+ # @return [Array<Sevgi::Geometry::Segment>] frozen segment collection
126
256
  def segments(approximate = false)
127
- approximate ? (@segments_approx ||= @segments.map(&:approx)) : @segments
257
+ approximate ? @segments.map(&:approx).freeze : @segments
128
258
  end
129
259
 
130
260
  # Affinity methods
131
261
 
262
+ # @!parse
263
+ # # Returns an element reflected across the selected axes.
264
+ # # @param x [Boolean] reflect across the x-axis
265
+ # # @param y [Boolean] reflect across the y-axis
266
+ # # @return [Sevgi::Geometry::Element::Lined]
267
+ # def reflect(x: true, y: true); end
268
+ #
269
+ # # Returns an element rotated around the origin.
270
+ # # @param a [Numeric] clockwise angle in degrees
271
+ # # @return [Sevgi::Geometry::Element::Lined]
272
+ # def rotate(a); end
273
+ #
274
+ # # Returns an element scaled from the origin.
275
+ # # @param sx [Numeric] x scale factor
276
+ # # @param sy [Numeric, Sevgi::Undefined] y scale factor, defaulting to sx
277
+ # # @return [Sevgi::Geometry::Element::Lined]
278
+ # def scale(sx, sy = Undefined); end
279
+ #
280
+ # # Returns an element skewed from the origin.
281
+ # # @param ax [Numeric] x-axis skew angle in degrees
282
+ # # @param ay [Numeric, Sevgi::Undefined] y-axis skew angle in degrees, defaulting to ax
283
+ # # @return [Sevgi::Geometry::Element::Lined]
284
+ # def skew(ax, ay = Undefined); end
285
+ #
286
+ # # Returns an element skewed along x.
287
+ # # @param a [Numeric] skew angle in degrees
288
+ # # @return [Sevgi::Geometry::Element::Lined]
289
+ # def skew_x(a); end
290
+ #
291
+ # # Returns an element skewed along y.
292
+ # # @param a [Numeric] skew angle in degrees
293
+ # # @return [Sevgi::Geometry::Element::Lined]
294
+ # def skew_y(a); end
295
+ #
296
+ # # Returns an element translated by offset.
297
+ # # @param dx [Numeric] x offset
298
+ # # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
299
+ # # @return [Sevgi::Geometry::Element::Lined]
300
+ # def translate(dx, dy = Undefined); end
132
301
  Geometry::Affinity.instance_methods.each do |transform|
133
302
  define_method(transform) do |*args, **kwargs, &block|
134
303
  self.class.new_by_points!(*points.map { it.public_send(transform, *args, **kwargs, &block) })
@@ -137,51 +306,123 @@ module Sevgi
137
306
 
138
307
  # Equality methods
139
308
 
140
- def eql?(other) = self.class == other.class && points(true) == other.points(true)
309
+ # Compares element points with optional numeric precision.
310
+ # @param other [Object] object to compare
311
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
312
+ # @return [Boolean]
313
+ def eq?(other, precision: nil)
314
+ other.instance_of?(self.class) &&
315
+ points.size == other.points.size &&
316
+ points.zip(other.points).all? { |left, right| left.eq?(right, precision:) }
317
+ end
318
+
319
+ # Reports strict element equality by class and exact points.
320
+ # @param other [Object] object to compare
321
+ # @return [Boolean]
322
+ def eql?(other) = other.instance_of?(self.class) && points == other.points
141
323
 
142
- def hash = [self.class, *points(true)].hash
324
+ # Returns a hash compatible with strict equality.
325
+ # @return [Integer]
326
+ def hash = [self.class, *points].hash
143
327
 
144
328
  alias == eql?
145
329
 
146
330
  # Interaction methods
147
331
 
148
- def equations = @equations ||= lines.map(&:equation)
149
-
332
+ # Returns immutable boundary equations for all lines.
333
+ # @return [Array<Sevgi::Geometry::Equation::Linear>] frozen equation collection
334
+ def equations = @equations ||= lines.map(&:equation).freeze
335
+
336
+ # Intersects the element boundary with an equation.
337
+ #
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.
342
+ # @param equation [Sevgi::Geometry::Equation] equation to intersect with
343
+ # @param precision [Integer, nil] decimal precision for returned points, or nil for the current function default
344
+ # @return [Array<Sevgi::Geometry::Point>] unique boundary intersection points
345
+ # @raise [Sevgi::Geometry::Error] when equation is not an equation
346
+ # @raise [Sevgi::PanicError] when the equation combination is not implemented
150
347
  def intersection(equation, precision: nil)
151
348
  equations
152
- .map do |candidate|
153
- equation.intersect(candidate).map { |point| point.approx(precision) }.select { |point| on?(point) }
154
- end
155
- .flatten
349
+ .flat_map { |candidate| equation.intersect(candidate).select { |point| on?(point) } }
350
+ .map { |point| point.approx(precision) }
156
351
  .uniq
157
352
  end
158
353
 
159
354
  # Properties
160
355
 
356
+ # Returns a line by index.
357
+ # @param i [Integer] line index
358
+ # @return [Sevgi::Geometry::Line]
359
+ # @raise [Sevgi::Geometry::Error] when no line exists for index
161
360
  def [](i) = lines[i].tap { |line| Error.("No line exist for index: #{i}") unless line }
162
361
 
163
- def box = Rect.([(xs = points.map(&:x)).min, (ys = points.map(&:y)).min], [xs.max, ys.max])
362
+ # Returns the bounding rectangle.
363
+ # @return [Sevgi::Geometry::Rect]
364
+ def box = Rect.from_corners([(xs = points.map(&:x)).min, (ys = points.map(&:y)).min], [xs.max, ys.max])
164
365
 
366
+ # Returns a point by index.
367
+ # @param i [Integer] point index
368
+ # @return [Sevgi::Geometry::Point]
369
+ # @raise [Sevgi::Geometry::Error] when no point exists for index
165
370
  def call(i) = points[i].tap { Error.("No point exist for index: #{i}") unless it }
166
371
 
372
+ # Returns the first segment.
373
+ # @return [Sevgi::Geometry::Segment]
167
374
  def head = @head ||= segments.first
168
375
 
376
+ # Returns immutable boundary lines derived from segments and points.
377
+ # @return [Array<Sevgi::Geometry::Line>] frozen line collection
169
378
  def lines
170
- @lines ||= segments.zip(points[...segments.size]).map { |segment, position|
171
- segment.line(position)
172
- }
379
+ @lines ||= segments
380
+ .zip(points[...segments.size])
381
+ .map { |segment, position|
382
+ segment.line(position)
383
+ }
384
+ .freeze
173
385
  end
174
386
 
387
+ # Returns the sum of segment lengths.
388
+ # @return [Float]
175
389
  def perimeter = @perimeter ||= segments.sum(&:length)
176
390
 
391
+ # Returns the last segment.
392
+ # @return [Sevgi::Geometry::Segment]
177
393
  def tail = @tail ||= segments.last
178
394
 
179
395
  # Relation methods
180
396
 
181
- def inside?(point) = on?(point) || pnpoly(points, point)
397
+ # Reports whether a point is inside or on the boundary.
398
+ #
399
+ # Open paths have no filled interior; for them this predicate is true
400
+ # only for points on the actual path boundary.
401
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
402
+ # @return [Boolean]
403
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
404
+ def inside?(point)
405
+ point = Tuple[Point, point]
406
+
407
+ return on?(point) if self.class.open?
182
408
 
183
- def on?(point) = lines.any? { it.over?(point) }
409
+ on?(point) || pnpoly(points, point)
410
+ end
411
+
412
+ # Reports whether a point is on the boundary.
413
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
414
+ # @return [Boolean]
415
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
416
+ def on?(point)
417
+ point = Tuple[Point, point]
418
+
419
+ lines.any? { it.over?(point) }
420
+ end
184
421
 
422
+ # Reports whether a point is outside the element boundary.
423
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
424
+ # @return [Boolean]
425
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
185
426
  def outside?(point) = !inside?(point)
186
427
 
187
428
  private
@@ -201,6 +442,11 @@ module Sevgi
201
442
  points.each_cons(2).map { Segment.(*it) }
202
443
  end
203
444
 
445
+ def freeze_geometry!
446
+ @points = @points.dup.freeze
447
+ @segments = @segments.dup.freeze
448
+ end
449
+
204
450
  # rubocop:disable Metrics/MethodLength
205
451
  # https://wrfranklin.org/Research/Short_Notes/pnpoly.html
206
452
  def pnpoly(vertices, test)
@@ -236,20 +482,20 @@ module Sevgi
236
482
  end
237
483
  end
238
484
 
485
+ # Reserved base for future arced elements.
486
+ # @api private
239
487
  class Arced < self
240
488
  end
489
+
490
+ private_constant :Arced
241
491
  # rubocop:enable Metrics/ClassLength
242
492
  end
243
493
 
244
494
  require_relative "elements/line"
245
- require_relative "elements/parm"
495
+ require_relative "elements/parallelogram"
246
496
  require_relative "elements/polygon"
247
497
  require_relative "elements/polyline"
248
498
  require_relative "elements/rect"
249
- require_relative "elements/tri"
250
-
251
- require_relative "elements/circle"
252
- require_relative "elements/curve"
253
- require_relative "elements/ellipse"
499
+ require_relative "elements/triangle"
254
500
  end
255
501
  end
@@ -1,30 +1,86 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "forwardable"
4
-
5
3
  module Sevgi
6
4
  module Geometry
7
- class Line < Element.lined(1, open: true)
8
- def self.[](length, angle, position: Origin) = new_by_segments(Segment[length, angle], position:)
5
+ # Generated superclass for Line.
6
+ # @api private
7
+ LineBase = Element.lined(1, open: true)
8
+ private_constant :LineBase
9
+
10
+ # Open lined element with one segment.
11
+ class Line < LineBase
12
+ # @overload [](length, angle, position: Origin)
13
+ # Builds a line from length and angle.
14
+ # @param length [Numeric] line length
15
+ # @param angle [Numeric] clockwise angle in degrees
16
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
17
+ # @return [Sevgi::Geometry::Line]
18
+ # @raise [Sevgi::Geometry::Error] when position cannot be coerced
19
+ def self.[](...) = from_length_angle(...)
20
+
21
+ # Builds a line from length and angle.
22
+ # @param length [Numeric] line length
23
+ # @param angle [Numeric] clockwise angle in degrees
24
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
25
+ # @return [Sevgi::Geometry::Line]
26
+ # @raise [Sevgi::Geometry::Error] when position cannot be coerced
27
+ def self.from_length_angle(length, angle, position: Origin) = new_by_segments(Segment[length, angle], position:)
28
+
29
+ # @overload from_points(starting, ending)
30
+ # Builds a line from two endpoints.
31
+ # @param starting [Sevgi::Geometry::Point, Array<Numeric>] starting point
32
+ # @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
33
+ # @return [Sevgi::Geometry::Line]
34
+ # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
35
+ def self.from_points(...) = new_by_points(...)
9
36
 
10
- extend Forwardable
37
+ # Returns the clockwise line angle in degrees.
38
+ # @return [Float]
39
+ def angle = head.angle
11
40
 
12
- def_delegators :head, :length, :angle
41
+ # Returns the ending point.
42
+ # @return [Sevgi::Geometry::Point]
43
+ def ending = points.last
13
44
 
14
- def_delegator :points, :first, :starting
15
- def_delegator :points, :last, :ending
45
+ # Reports whether a point is left of the line equation.
46
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
47
+ # @return [Boolean]
48
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
49
+ def left?(point) = equation.left?(point)
16
50
 
17
- def_delegators :equation, :left?, :right?
51
+ # Returns the line segment length.
52
+ # @return [Float]
53
+ def length = head.length
18
54
 
19
- alias a angle
20
- alias l length
55
+ # Reports whether a point is right of the line equation.
56
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
57
+ # @return [Boolean]
58
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
59
+ def right?(point) = equation.right?(point)
21
60
 
22
- def draw!(node, **) = node.Cline(x1: position.x, y1: position.y, x2: ending.x, y2: ending.y, **)
61
+ # Returns the starting point.
62
+ # @return [Sevgi::Geometry::Point]
63
+ def starting = points.first
23
64
 
24
- # TODO: xxx
25
- def over?(point) = within_range?(point) && equation.on?(point)
65
+ # Draws the line into a graphics node.
66
+ # @param node [Object] graphics node receiving the drawing command
67
+ # @return [Object] graphics node command result
68
+ def draw!(node, **) = node.LineTo(x1: position.x, y1: position.y, x2: ending.x, y2: ending.y, **)
69
+
70
+ # Reports whether a point lies on the finite line segment.
71
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
72
+ # @return [Boolean]
73
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
74
+ def over?(point)
75
+ point = Tuple[Point, point]
76
+
77
+ within_range?(point) && equation.on?(point)
78
+ end
26
79
 
27
- def shift(distance) = translate(distance * F.sin(angle), distance * F.cos(angle))
80
+ # Returns a parallel line shifted by a signed perpendicular offset.
81
+ # @param distance [Numeric] signed perpendicular offset
82
+ # @return [Sevgi::Geometry::Line]
83
+ def shift(distance) = translate(distance * F.sin(angle), -distance * F.cos(angle))
28
84
 
29
85
  private
30
86