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.
@@ -7,8 +7,52 @@ module Sevgi
7
7
  PolylineBase = Element.lined(open: true)
8
8
  private_constant :PolylineBase
9
9
 
10
- # Variable-size open lined element.
10
+ # Variable-size open lined element with at least two points.
11
+ # @!method self.[](*segments, position: Origin)
12
+ # Builds a polyline from ordered segments.
13
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] ordered segments
14
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
15
+ # @return [Sevgi::Geometry::Polyline]
16
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
17
+ # @!method self.call(*points)
18
+ # Builds a polyline from ordered points.
19
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] ordered points
20
+ # @return [Sevgi::Geometry::Polyline]
21
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
22
+ # @!method self.from_segments(*segments, position: Origin)
23
+ # Builds a polyline from ordered segments.
24
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] ordered segments
25
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
26
+ # @return [Sevgi::Geometry::Polyline]
27
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
28
+ # @!method self.from_points(*points)
29
+ # Builds a polyline from ordered points.
30
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] ordered points
31
+ # @return [Sevgi::Geometry::Polyline]
32
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
33
+ # @!method starting
34
+ # Returns the first point in the directed path.
35
+ # @return [Sevgi::Geometry::Point]
36
+ # @!method ending
37
+ # Returns the last point in the directed path.
38
+ # @return [Sevgi::Geometry::Point]
39
+ # @!method reverse
40
+ # Returns the same trace with opposite traversal.
41
+ # @return [Sevgi::Geometry::Polyline]
42
+ # @example Pair mathematical notation with English conveniences
43
+ # Sevgi::Geometry::Polyline[[2, 0], [1, 90]] == Sevgi::Geometry::Polyline.from_segments([2, 0], [1, 90])
44
+ # Sevgi::Geometry::Polyline.([0, 0], [2, 0]) == Sevgi::Geometry::Polyline.from_points([0, 0], [2, 0])
45
+ # @example Measure and query an open path
46
+ # path = Sevgi::Geometry::Polyline.([0, 0], [3, 0], [3, 4])
47
+ # path.length # => 7.0
48
+ # path.on?([2, 0]) # => true
49
+ # path.inside?([1, 1]) # => false
11
50
  class Polyline < PolylineBase
51
+ private
52
+
53
+ def validate_geometry!
54
+ Error.("Polyline requires at least two points") if points.size < 2
55
+ end
12
56
  end
13
57
  end
14
58
  end
@@ -7,7 +7,36 @@ module Sevgi
7
7
  RectBase = Element.lined(4)
8
8
  private_constant :RectBase
9
9
 
10
- # Closed four-sided rectangle aligned to the screen axes.
10
+ # Closed four-sided rectangle aligned to the screen axes. Affine operations return Rect while the result remains
11
+ # axis-aligned and widen to {Parallelogram} after rotation or skew changes that category. A Square similarly widens
12
+ # to Rect after unequal scaling.
13
+ # @example Inspect corners, sides, and containment
14
+ # rect = Sevgi::Geometry::Rect[8, 4, position: [2, 3]]
15
+ # rect.top_left.deconstruct # => [2.0, 3.0]
16
+ # rect.right.length # => 4.0
17
+ # rect.inside?([5, 5]) # => true
18
+ # @example Observe semantic widening after affine transforms
19
+ # Sevgi::Geometry::Rect[8, 4].rotate(30).class # => Sevgi::Geometry::Parallelogram
20
+ # Sevgi::Geometry::Square[4].scale(2, 1).class # => Sevgi::Geometry::Rect
21
+ # @!attribute [r] A
22
+ # @return [Sevgi::Geometry::Point] top-left vertex
23
+ # @!attribute [r] B
24
+ # @return [Sevgi::Geometry::Point] top-right vertex
25
+ # @!attribute [r] C
26
+ # @return [Sevgi::Geometry::Point] bottom-right vertex
27
+ # @!attribute [r] D
28
+ # @return [Sevgi::Geometry::Point] bottom-left vertex
29
+ # @!attribute [r] AB
30
+ # @return [Sevgi::Geometry::Line] top side
31
+ # @!attribute [r] BC
32
+ # @return [Sevgi::Geometry::Line] right side
33
+ # @!attribute [r] CD
34
+ # @return [Sevgi::Geometry::Line] bottom side
35
+ # @!attribute [r] DA
36
+ # @return [Sevgi::Geometry::Line] left side
37
+ # @!method perimeter
38
+ # Returns the closed path perimeter.
39
+ # @return [Float]
11
40
  class Rect < RectBase
12
41
  # @overload [](width, height, position: Origin)
13
42
  # Builds a rectangle from size and top-left position.
@@ -15,8 +44,30 @@ module Sevgi
15
44
  # @param height [Numeric] rectangle height
16
45
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
17
46
  # @return [Sevgi::Geometry::Rect]
18
- # @raise [Sevgi::Geometry::Error] when position cannot be coerced
19
- def self.[](...) = from_size(...)
47
+ # @raise [Sevgi::Geometry::Error] when position cannot be coerced or a dimension is negative
48
+ # @example Mathematical notation and English convenience are equivalent
49
+ # Sevgi::Geometry::Rect[3, 4] == Sevgi::Geometry::Rect.from_size(3, 4)
50
+ def self.[](width, height, position: Origin) = construct(width, height, position:)
51
+
52
+ # Constructs a rectangle for canonical size notation.
53
+ # @param width [Numeric] rectangle width
54
+ # @param height [Numeric] rectangle height
55
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
56
+ # @return [Sevgi::Geometry::Rect]
57
+ # @raise [Sevgi::Geometry::Error] when position or a dimension is invalid
58
+ # @api private
59
+ def self.construct(width, height, position:)
60
+ width = dimension!(:width, width)
61
+ height = dimension!(:height, height)
62
+
63
+ new_by_segments(
64
+ Segment.rightward(width),
65
+ Segment.downward(height),
66
+ Segment.leftward(width),
67
+ Segment.upward(height),
68
+ position:
69
+ )
70
+ end
20
71
 
21
72
  # @overload call(top_left, bottom_right)
22
73
  # Builds a rectangle from two opposite corners.
@@ -24,46 +75,96 @@ module Sevgi
24
75
  # @param bottom_right [Sevgi::Geometry::Point, Array<Numeric>] bottom-right corner
25
76
  # @return [Sevgi::Geometry::Rect]
26
77
  # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
27
- def self.call(...) = from_corners(...)
78
+ # @example Mathematical notation and English convenience are equivalent
79
+ # Sevgi::Geometry::Rect.([0, 0], [3, 4]) == Sevgi::Geometry::Rect.from_corners([0, 0], [3, 4])
80
+ def self.call(top_left, bottom_right)
81
+ top_left, bottom_right = Tuples[Point, top_left, bottom_right]
82
+ left, right = [top_left.x, bottom_right.x].minmax
83
+ top, bottom = [top_left.y, bottom_right.y].minmax
84
+ width, height = right - left, bottom - top
85
+
86
+ if self <= Square
87
+ Error.("Square corners must define equal dimensions") unless F.eq?(width, height)
88
+
89
+ return self[width, position: [left, top]]
90
+ end
91
+
92
+ self[width, height, position: [left, top]]
93
+ end
28
94
 
29
95
  # Builds a rectangle from two opposite corners.
30
96
  # @param top_left [Sevgi::Geometry::Point, Array<Numeric>] top-left corner
31
97
  # @param bottom_right [Sevgi::Geometry::Point, Array<Numeric>] bottom-right corner
32
98
  # @return [Sevgi::Geometry::Rect]
33
99
  # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
34
- def self.from_corners(top_left, bottom_right)
35
- top_left, bottom_right = Tuples[Point, top_left, bottom_right]
36
- width = (bottom_right.x - top_left.x).abs
37
-
38
- new_by_points(
39
- top_left,
40
- top_left.translate(width, 0.0),
41
- bottom_right,
42
- bottom_right.translate(-width, 0.0)
43
- )
44
- end
100
+ def self.from_corners(top_left, bottom_right) = call(top_left, bottom_right)
45
101
 
46
102
  # Builds a rectangle from size and top-left position.
47
103
  # @param width [Numeric] rectangle width
48
104
  # @param height [Numeric] rectangle height
49
105
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
50
106
  # @return [Sevgi::Geometry::Rect]
51
- # @raise [Sevgi::Geometry::Error] when position cannot be coerced
52
- def self.from_size(width, height, position: Origin)
53
- new_by_segments(
54
- Segment.rightward(width),
55
- Segment.downward(height),
56
- Segment.leftward(width),
57
- Segment.upward(height),
58
- position:
59
- )
107
+ # @raise [Sevgi::Geometry::Error] when position cannot be coerced or a dimension is negative
108
+ def self.from_size(width, height, position: Origin) = self[width, height, position:]
109
+
110
+ class << self
111
+ private
112
+
113
+ def affine(*points)
114
+ left, right, top, bottom = bounds(points)
115
+ width, height = right - left, bottom - top
116
+
117
+ unless axis_aligned?(points, left, right, top, bottom)
118
+ return Parallelogram.call(*points.first(4))
119
+ end
120
+
121
+ klass = self <= Square && F.eq?(width, height) ? Square : Rect
122
+ return klass[width, position: [left, top]] if klass == Square
123
+
124
+ klass[width, height, position: [left, top]]
125
+ end
126
+
127
+ def approximate(*points)
128
+ new_by_points!(*points)
129
+ rescue Error
130
+ return Rect.send(:new_by_points!, *points) if self <= Square
131
+
132
+ super
133
+ end
134
+
135
+ def axis_aligned?(points, left, right, top, bottom)
136
+ expected = [[left, top], [right, top], [right, bottom], [left, bottom]]
137
+ vertices = points.first(4)
138
+
139
+ expected.all? { |corner| vertices.any? { it.eq?(Point[*corner]) } }
140
+ end
141
+
142
+ def bounds(points)
143
+ vertices = points.first(4)
144
+ [vertices.map(&:x).min, vertices.map(&:x).max, vertices.map(&:y).min, vertices.map(&:y).max]
145
+ end
146
+
147
+ def dimension!(name, value)
148
+ value = Real[name, value]
149
+ Error.("Rectangle #{name} cannot be negative") if value.negative?
150
+
151
+ value
152
+ end
60
153
  end
61
154
 
155
+ private_class_method :construct, :from_points, :from_segments
156
+
62
157
  # Draws the rectangle into a graphics node.
63
158
  # @param node [Object] graphics node receiving the drawing command
64
159
  # @return [Object] graphics node command result
65
160
  def draw!(node, **) = node.rect(x: position.x, y: position.y, width: width, height: height, **)
66
161
 
162
+ private :draw!
163
+
164
+ # Returns the rectangle center.
165
+ # @return [Sevgi::Geometry::Point]
166
+ def center = Point.midpoint(top_left, bottom_right)
167
+
67
168
  # Returns rectangle height.
68
169
  # @return [Float]
69
170
  def height = @height ||= segments[1].length
@@ -111,19 +212,52 @@ module Sevgi
111
212
  %i[top right bottom left].each_with_index do |side, i|
112
213
  define_method(side) { lines[i] }
113
214
  end
215
+
216
+ private
217
+
218
+ def validate_geometry!
219
+ left, right, top, bottom = self.class.send(:bounds, points)
220
+ expected = [[left, top], [right, top], [right, bottom], [left, bottom], [left, top]]
221
+ valid = points.zip(expected).all? { |point, pair| point.eq?(Point[*pair]) }
222
+
223
+ Error.("Rectangle points must form an axis-aligned rectangle") unless valid
224
+ end
114
225
  end
115
226
 
116
- # Rectangle with equal width and height.
227
+ # Rectangle with equal width and height. Use {#width} or {#height} for its side length. Inherited
228
+ # {Element::Lined#length} returns the complete path length.
229
+ # @example Construct the same square from opposite corners
230
+ # Sevgi::Geometry::Square.([0, 0], [5, 5]) == Sevgi::Geometry::Square.from_corners([0, 0], [5, 5])
117
231
  class Square < Rect
118
- # @return [Float] side length
119
- alias length width
232
+ # Builds a square from side length and top-left position.
233
+ # @param length [Numeric] side length
234
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
235
+ # @return [Sevgi::Geometry::Square]
236
+ # @raise [Sevgi::Geometry::Error] when position cannot be coerced or length is negative
237
+ # @example Mathematical notation and English convenience are equivalent
238
+ # Sevgi::Geometry::Square[5] == Sevgi::Geometry::Square.from_size(5)
239
+ def self.[](length, position: Origin) = construct(length, length, position:)
240
+
241
+ # Builds a square from two opposite corners.
242
+ # @param top_left [Sevgi::Geometry::Point, Array<Numeric>] top-left corner
243
+ # @param bottom_right [Sevgi::Geometry::Point, Array<Numeric>] bottom-right corner
244
+ # @return [Sevgi::Geometry::Square]
245
+ # @raise [Sevgi::Geometry::Error] when points are invalid or define unequal dimensions
246
+ def self.from_corners(top_left, bottom_right) = call(top_left, bottom_right)
120
247
 
121
248
  # Builds a square from side length and top-left position.
122
249
  # @param length [Numeric] side length
123
250
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
124
251
  # @return [Sevgi::Geometry::Square]
125
- # @raise [Sevgi::Geometry::Error] when position cannot be coerced
126
- def self.[](length, position: Origin) = from_size(length, length, position:)
252
+ # @raise [Sevgi::Geometry::Error] when position or length is invalid
253
+ def self.from_size(length, position: Origin) = self[length, position:]
254
+
255
+ private
256
+
257
+ def validate_geometry!
258
+ super
259
+ Error.("Square sides must have equal length") unless F.eq?(width, height)
260
+ end
127
261
  end
128
262
  end
129
263
  end
@@ -7,12 +7,53 @@ module Sevgi
7
7
  TriangleBase = Element.lined(3)
8
8
  private_constant :TriangleBase
9
9
 
10
- # Closed three-sided element built from two non-collinear adjacent segments.
10
+ # Closed three-sided element built from non-collinear segments or points. Every construction path rejects
11
+ # degenerate triangles. Affine operations retain Triangle when the transformed points remain non-degenerate.
12
+ # @!method self.call(*points)
13
+ # Builds a triangle from three boundary points.
14
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] three boundary points
15
+ # @return [Sevgi::Geometry::Triangle]
16
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or form a degenerate triangle
17
+ # @!method self.from_segments(segment_a, segment_b, position: Origin)
18
+ # Builds a triangle from two adjacent segments and derives the closing side.
19
+ # @param segment_a [Sevgi::Geometry::Segment, Array<Numeric>] first adjacent segment
20
+ # @param segment_b [Sevgi::Geometry::Segment, Array<Numeric>] second adjacent segment
21
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
22
+ # @return [Sevgi::Geometry::Triangle]
23
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or form a degenerate triangle
24
+ # @!method self.from_points(*points)
25
+ # Builds a triangle from three boundary points.
26
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] three boundary points
27
+ # @return [Sevgi::Geometry::Triangle]
28
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or form a degenerate triangle
29
+ # @!attribute [r] A
30
+ # @return [Sevgi::Geometry::Point] first vertex
31
+ # @!attribute [r] B
32
+ # @return [Sevgi::Geometry::Point] second vertex
33
+ # @!attribute [r] C
34
+ # @return [Sevgi::Geometry::Point] third vertex
35
+ # @!attribute [r] AB
36
+ # @return [Sevgi::Geometry::Line] side from A to B
37
+ # @!attribute [r] BC
38
+ # @return [Sevgi::Geometry::Line] side from B to C
39
+ # @!attribute [r] CA
40
+ # @return [Sevgi::Geometry::Line] side from C to A
41
+ # @!method perimeter
42
+ # Returns the closed path perimeter.
43
+ # @return [Float]
44
+ # @example Pair mathematical notation with English conveniences
45
+ # Sevgi::Geometry::Triangle[[2, 0], [2, 90]] == Sevgi::Geometry::Triangle.from_segments([2, 0], [2, 90])
46
+ # Sevgi::Geometry::Triangle.([0, 0], [2, 0], [2, 2]) == Sevgi::Geometry::Triangle.from_points([0, 0], [2, 0], [2, 2])
47
+ # @example Use named vertices and sides
48
+ # triangle = Sevgi::Geometry::Triangle.([0, 0], [3, 0], [3, 4])
49
+ # triangle.C.deconstruct # => [3.0, 4.0]
50
+ # triangle.AB.length # => 3.0
51
+ # triangle.perimeter # => 12.0
11
52
  class Triangle < TriangleBase
12
53
  # Builds a triangle from two adjacent segments.
13
54
  #
14
55
  # The closing segment is the direct vector from the end of `segment_b`
15
- # back to `position`. Segment order controls orientation; reversing the
56
+ # back to `position`. Segment order controls orientation. Reversing the
16
57
  # inputs returns the corresponding opposite orientation. Zero-length or
17
58
  # collinear inputs are rejected using the current numeric precision.
18
59
  # @param segment_a [Sevgi::Geometry::Segment, Array<Numeric>] first segment
@@ -27,21 +68,26 @@ module Sevgi
27
68
  new_by_segments(a, b, closing_segment(a, b), position:)
28
69
  end
29
70
 
30
- def self.closing_segment(a, b)
31
- Segment.(b.ending(a.ending(Origin)), Origin)
32
- end
71
+ class << self
72
+ private
73
+
74
+ def closing_segment(a, b)
75
+ Segment.(b.ending(a.ending(Origin)), Origin)
76
+ end
33
77
 
34
- def self.cross(a, b) = (a.x * b.y) - (a.y * b.x)
78
+ def validate!(a, b)
79
+ middle = a.ending(Origin)
80
+ return unless F.zero?(a.length) || F.zero?(b.length) || Point.collinear?(Origin, middle, b.ending(middle))
35
81
 
36
- def self.validate!(a, b)
37
- if F.zero?(a.length) ||
38
- F.zero?(b.length) ||
39
- F.zero?(cross(a, b))
40
82
  Error.("Triangle segments must form a non-degenerate triangle")
41
83
  end
42
84
  end
43
85
 
44
- private_class_method :closing_segment, :cross, :validate!
86
+ private
87
+
88
+ def validate_geometry!
89
+ self.class.send(:validate!, segments[0], segments[1])
90
+ end
45
91
  end
46
92
  end
47
93
  end