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.
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Geometry
5
+ # rubocop:disable Metrics/ClassLength
6
+
7
+ # Immutable ellipse with positive radii, a center, and clockwise axis rotation.
8
+ # Parameter angles belong to the local ellipse axes, not to polar directions from the center.
9
+ # @example Select a finite boundary and inspect its endpoints
10
+ # ellipse = Sevgi::Geometry::Ellipse[4, 2, position: [10, 20]]
11
+ # ellipse.point(90).deconstruct # => [10.0, 22.0]
12
+ # ellipse.arc(starting_angle: 0, extent: 90).ending == ellipse.point(90)
13
+ class Ellipse < Element::Arced
14
+ # Builds an ellipse from local radii and its position.
15
+ # @param rx [Numeric] positive local x radius
16
+ # @param ry [Numeric] positive local y radius
17
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] ellipse center
18
+ # @param rotation [Numeric] clockwise rotation in degrees
19
+ # @return [Sevgi::Geometry::Ellipse]
20
+ # @raise [Sevgi::Geometry::Error] when inputs are not finite real values or a radius is not positive
21
+ def self.[](rx, ry, position: Origin, rotation: 0) = new(rx, ry, position:, rotation:)
22
+
23
+ def self.close? = true
24
+ private_class_method :close?
25
+
26
+ # @return [Sevgi::Geometry::Point] ellipse center
27
+ attr_reader :position
28
+ # @return [Float] clockwise rotation of the local axes in degrees
29
+ attr_reader :rotation
30
+ # @return [Float] positive local x radius
31
+ attr_reader :rx
32
+ # @return [Float] positive local y radius
33
+ attr_reader :ry
34
+
35
+ # Creates an ellipse. Use the bracket constructor.
36
+ # @param rx [Numeric] positive local x radius
37
+ # @param ry [Numeric] positive local y radius
38
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] center
39
+ # @param rotation [Numeric] clockwise rotation in degrees
40
+ # @return [void]
41
+ # @raise [Sevgi::Geometry::Error] when an input is invalid
42
+ def initialize(rx, ry, position:, rotation:)
43
+ super()
44
+ @rx, @ry = [[:rx, rx], [:ry, ry]].map do |field, value|
45
+ value = Real[field, value]
46
+ Error.("Ellipse #{field} must be positive") unless value.positive?
47
+ value
48
+ end
49
+
50
+ @position = Tuple[Point, position]
51
+ @rotation = Real[:rotation, rotation]
52
+ end
53
+
54
+ # Rebuilds an ellipse from rounded canonical fields.
55
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
56
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
57
+ # @raise [Sevgi::Geometry::Error] when rounding makes a radius zero
58
+ # @raise [Sevgi::ArgumentError] when precision is invalid
59
+ def approx(precision = nil)
60
+ rebuild(
61
+ F.approx(rx, precision),
62
+ F.approx(ry, precision),
63
+ position.approx(precision),
64
+ F.approx(rotation, precision)
65
+ )
66
+ end
67
+
68
+ # Selects a finite, directed arc on this ellipse.
69
+ # @param starting_angle [Numeric] local starting parameter angle in degrees
70
+ # @param extent [Numeric] signed angular extent strictly between -360 and 360 degrees
71
+ # @return [Sevgi::Geometry::Arc]
72
+ # @raise [Sevgi::Geometry::Error] when angles are invalid
73
+ def arc(extent:, starting_angle: 0)
74
+ parent = is_a?(Circle) ? Ellipse[rx, ry, position:] : self
75
+ Arc.send(:new, parent, starting_angle:, extent:)
76
+ end
77
+
78
+ # rubocop:disable Metrics/AbcSize
79
+
80
+ # Returns the axis-aligned bounds without display rounding.
81
+ # @return [Sevgi::Geometry::Rect]
82
+ def box
83
+ cosine, sine = F.cos(rotation), F.sin(rotation)
84
+ dx = ::Math.hypot(rx * cosine, ry * sine)
85
+ dy = ::Math.hypot(rx * sine, ry * cosine)
86
+ Rect.from_corners([position.x - dx, position.y - dy], [position.x + dx, position.y + dy])
87
+ end
88
+
89
+ # rubocop:enable Metrics/AbcSize
90
+
91
+ # Reports whether the radii are exactly equal.
92
+ # @return [Boolean]
93
+ def circular? = rx == ry
94
+
95
+ # rubocop:disable Metrics/AbcSize
96
+
97
+ # Draws an SVG ellipse using original geometric values.
98
+ # @param node [Object] graphics node receiving the element
99
+ # @param attributes [Hash] SVG attributes, including an optional outer transform
100
+ # @return [Object] graphics command result
101
+ def draw(node, **attributes)
102
+ unless rotation.zero?
103
+ transform = "rotate(#{rotation} #{position.x} #{position.y})"
104
+ attributes = attributes.merge(transform: [attributes[:transform], transform].compact.join(" "))
105
+ end
106
+
107
+ node.ellipse(cx: position.x, cy: position.y, rx:, ry:, **attributes)
108
+ end
109
+
110
+ # rubocop:enable Metrics/AbcSize
111
+
112
+ # Reports whether the ellipse has zero angular extent. A complete ellipse is never empty.
113
+ # @return [Boolean]
114
+ def empty? = false
115
+
116
+ # Returns the complete quadratic carrier.
117
+ # @return [Sevgi::Geometry::Equation::Quadratic]
118
+ def equation = @equation ||= Equation.quadratic(*coefficients, origin: position)
119
+
120
+ # Returns the immutable collection containing the complete carrier.
121
+ # @return [Array<Sevgi::Geometry::Equation::Quadratic>]
122
+ def equations = @equations ||= [equation].freeze
123
+
124
+ # Reports whether a point is inside or on the boundary.
125
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
126
+ # @return [Boolean]
127
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
128
+ def inside?(point)
129
+ point = Tuple[Point, point]
130
+ local = local(point)
131
+ ::Math.hypot(local.x / rx, local.y / ry) < 1.0 || on?(point)
132
+ end
133
+
134
+ # Returns the perimeter with thread-independent numerical accuracy.
135
+ # @return [Float]
136
+ # @raise [Sevgi::Geometry::Error] when length is not finite or integration cannot meet its error target
137
+ def length = @length ||= arc_length(0, 360)
138
+
139
+ # Reports whether a point matches its radial boundary reference at the current coordinate precision.
140
+ # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
141
+ # @return [Boolean]
142
+ # @raise [Sevgi::Geometry::Error] when point cannot be coerced
143
+ def on?(point)
144
+ point = Tuple[Point, point]
145
+ point != position && point.eq?(self.point(parameter(point)))
146
+ end
147
+
148
+ # Returns the closed boundary length.
149
+ # @return [Float]
150
+ def perimeter = length
151
+
152
+ # Evaluates the boundary at a local parameter angle.
153
+ # @param angle [Numeric] clockwise local angle in degrees
154
+ # @return [Sevgi::Geometry::Point]
155
+ # @raise [Sevgi::Geometry::Error] when angle or the resulting coordinates are not finite
156
+ def point(angle)
157
+ angle = Real[:angle, angle] % 360.0
158
+ Point[rx * F.cos(angle), ry * F.sin(angle)].rotate(rotation).translate(position.x, position.y)
159
+ end
160
+
161
+ # Returns a reflected copy, preserving Circle where applicable.
162
+ # @param x [Boolean] reflect across the x-axis
163
+ # @param y [Boolean] reflect across the y-axis
164
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
165
+ # @raise [Sevgi::Geometry::Error] when a flag is not Boolean
166
+ def reflect(x: true, y: true) = affine(:reflect, x:, y:).first
167
+
168
+ # Rotates the center around the origin and the ellipse axes by the same angle.
169
+ # @param angle [Numeric] clockwise angle in degrees
170
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
171
+ # @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
172
+ def rotate(angle)
173
+ angle = Real[:angle, angle]
174
+ rebuild(rx, ry, position.rotate(angle), rotation + angle)
175
+ end
176
+
177
+ # rubocop:disable Metrics/AbcSize
178
+
179
+ # Scales the ellipse from the origin. Unequal factors can widen Circle to Ellipse.
180
+ # @param sx [Numeric] x scale factor
181
+ # @param sy [Numeric, Sevgi::Undefined] y factor, defaulting to sx
182
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
183
+ # @raise [Sevgi::Geometry::Error] when the transform is singular or the resulting geometry is invalid
184
+ def scale(sx, sy = Undefined)
185
+ sx, sy = Real[:sx, sx], Real[:sy, Undefined.default(sy, sx)]
186
+ return affine(:scale, sx, sy).first unless sx == sy
187
+
188
+ rebuild(rx * sx.abs, ry * sy.abs, position.scale(sx, sy), rotation + (sx.negative? ? 180 : 0))
189
+ end
190
+
191
+ # rubocop:enable Metrics/AbcSize
192
+
193
+ # Skews the ellipse from the origin.
194
+ # @param ax [Numeric] x-axis skew angle in degrees
195
+ # @param ay [Numeric, Sevgi::Undefined] y-axis angle, defaulting to ax
196
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
197
+ # @raise [Sevgi::Geometry::Error] when the transform is singular or the resulting geometry is invalid
198
+ def skew(ax, ay = Undefined) = affine(:skew, ax, ay).first
199
+
200
+ # Skews the ellipse along x.
201
+ # @param angle [Numeric] skew angle in degrees
202
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
203
+ # @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
204
+ def skew_x(angle) = affine(:skew_x, angle).first
205
+
206
+ # Skews the ellipse along y.
207
+ # @param angle [Numeric] skew angle in degrees
208
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
209
+ # @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
210
+ def skew_y(angle) = affine(:skew_y, angle).first
211
+
212
+ # Returns a translated copy.
213
+ # @param dx [Numeric] x offset
214
+ # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
215
+ # @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
216
+ # @raise [Sevgi::Geometry::Error] when offsets or the resulting center are invalid
217
+ def translate(dx, dy = Undefined) = rebuild(rx, ry, position.translate(dx, dy), rotation)
218
+
219
+ alias center position
220
+
221
+ private
222
+
223
+ def affine(...) = Affine.new(self).transform(...)
224
+
225
+ def arc_length(starting_angle, extent)
226
+ return Real[:length, rx * F.to_radians(extent.abs)] if circular?
227
+
228
+ Length.new(rx, ry).integrate(starting_angle, extent)
229
+ end
230
+
231
+ # rubocop:disable-next Metrics/AbcSize
232
+ def coefficients
233
+ cosine, sine = F.cos(rotation), F.sin(rotation)
234
+ a = ((cosine / rx) ** 2) + ((sine / ry) ** 2)
235
+ b = 2 * cosine * sine * (((1.0 / rx) ** 2) - ((1.0 / ry) ** 2))
236
+ c = ((sine / rx) ** 2) + ((cosine / ry) ** 2)
237
+ [a, b, c, 0, 0, -1]
238
+ end
239
+
240
+ # rubocop:disable-next Metrics/AbcSize
241
+ def extrema
242
+ cosine, sine = F.cos(rotation), F.sin(rotation)
243
+ x = F.atan2(-ry * sine, rx * cosine)
244
+ y = F.atan2(ry * cosine, rx * sine)
245
+ [x, x + 180, y, y + 180]
246
+ end
247
+
248
+ def local(point) = point.translate(-position.x, -position.y).rotate(-rotation)
249
+
250
+ def parameter(point)
251
+ point = local(point)
252
+ F.to_degrees(::Math.atan2(point.y / ry, point.x / rx))
253
+ end
254
+
255
+ def rebuild(rx, ry, position, rotation)
256
+ return Circle[rx, position:] if is_a?(Circle) && rx == ry
257
+
258
+ Ellipse[rx, ry, position:, rotation:]
259
+ end
260
+
261
+ def state = [position, rx, ry, rotation]
262
+
263
+ require_relative "ellipse/affine"
264
+ require_relative "ellipse/length"
265
+ end
266
+
267
+ # rubocop:enable Metrics/ClassLength
268
+ end
269
+ end
@@ -7,7 +7,41 @@ module Sevgi
7
7
  LineBase = Element.lined(1, open: true)
8
8
  private_constant :LineBase
9
9
 
10
- # Open lined element with one segment.
10
+ # Finite, directed line between two endpoints.
11
+ #
12
+ # Direction affects {#left?}, {#right?}, and the sign of {#shift}. Use
13
+ # {#equation} when the corresponding infinite line is required. {#over?}
14
+ # deliberately tests only the finite extent between the endpoints.
15
+ # @example Query sides of a directed line in screen coordinates
16
+ # line = Sevgi::Geometry::Line.([0, 0], [10, 0])
17
+ # line.left?([5, -2]) # => true
18
+ # line.right?([5, 2]) # => true
19
+ # line.shift(2).starting.deconstruct # => [0.0, -2.0]
20
+ # @example Distinguish the finite segment from its infinite equation
21
+ # line = Sevgi::Geometry::Line.([0, 0], [10, 0])
22
+ # line.over?([5, 0]) # => true
23
+ # line.over?([15, 0]) # => false
24
+ # @!method self.call(starting, ending)
25
+ # Builds a line from two endpoints.
26
+ # @param starting [Sevgi::Geometry::Point, Array<Numeric>] starting point
27
+ # @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
28
+ # @return [Sevgi::Geometry::Line]
29
+ # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
30
+ # @!method starting
31
+ # Returns the first point in the directed path.
32
+ # @return [Sevgi::Geometry::Point]
33
+ # @!method ending
34
+ # Returns the last point in the directed path.
35
+ # @return [Sevgi::Geometry::Point]
36
+ # @!method reverse
37
+ # Returns the same finite trace with opposite traversal.
38
+ # @return [Sevgi::Geometry::Line]
39
+ # @!attribute [r] A
40
+ # @return [Sevgi::Geometry::Point] starting point
41
+ # @!attribute [r] B
42
+ # @return [Sevgi::Geometry::Point] ending point
43
+ # @!attribute [r] AB
44
+ # @return [Sevgi::Geometry::Line] line from A to B
11
45
  class Line < LineBase
12
46
  # @overload [](length, angle, position: Origin)
13
47
  # Builds a line from length and angle.
@@ -15,16 +49,18 @@ module Sevgi
15
49
  # @param angle [Numeric] clockwise angle in degrees
16
50
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
17
51
  # @return [Sevgi::Geometry::Line]
18
- # @raise [Sevgi::Geometry::Error] when position cannot be coerced
19
- def self.[](...) = from_length_angle(...)
52
+ # @raise [Sevgi::Geometry::Error] when length, angle, or position cannot be coerced to finite geometry values
53
+ # @example Mathematical notation and English convenience are equivalent
54
+ # Sevgi::Geometry::Line[5, 30] == Sevgi::Geometry::Line.from_length_angle(5, 30)
55
+ def self.[](length, angle, position: Origin) = new_by_segments(Segment[length, angle], position:)
20
56
 
21
57
  # Builds a line from length and angle.
22
58
  # @param length [Numeric] line length
23
59
  # @param angle [Numeric] clockwise angle in degrees
24
60
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
25
61
  # @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:)
62
+ # @raise [Sevgi::Geometry::Error] when length, angle, or position cannot be coerced to finite geometry values
63
+ def self.from_length_angle(length, angle, position: Origin) = self[length, angle, position:]
28
64
 
29
65
  # @overload from_points(starting, ending)
30
66
  # Builds a line from two endpoints.
@@ -32,41 +68,37 @@ module Sevgi
32
68
  # @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
33
69
  # @return [Sevgi::Geometry::Line]
34
70
  # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
35
- def self.from_points(...) = new_by_points(...)
71
+ # @example Mathematical notation and English convenience are equivalent
72
+ # Sevgi::Geometry::Line.([0, 0], [3, 4]) == Sevgi::Geometry::Line.from_points([0, 0], [3, 4])
73
+ def self.from_points(...) = call(...)
74
+
75
+ private_class_method :from_segments
36
76
 
37
77
  # Returns the clockwise line angle in degrees.
38
78
  # @return [Float]
39
79
  def angle = head.angle
40
80
 
41
- # Returns the ending point.
42
- # @return [Sevgi::Geometry::Point]
43
- def ending = points.last
44
-
45
- # Reports whether a point is left of the line equation.
81
+ # Reports whether a point is left of the directed line from {#starting} to {#ending} in screen coordinates.
82
+ # Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
46
83
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
47
84
  # @return [Boolean]
48
85
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
49
- def left?(point) = equation.left?(point)
50
-
51
- # Returns the line segment length.
52
- # @return [Float]
53
- def length = head.length
86
+ def left?(point) = F.lt?(side(point), 0.0)
54
87
 
55
- # Reports whether a point is right of the line equation.
88
+ # Reports whether a point is right of the directed line from {#starting} to {#ending} in screen coordinates.
89
+ # Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
56
90
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
57
91
  # @return [Boolean]
58
92
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
59
- def right?(point) = equation.right?(point)
60
-
61
- # Returns the starting point.
62
- # @return [Sevgi::Geometry::Point]
63
- def starting = points.first
93
+ def right?(point) = F.gt?(side(point), 0.0)
64
94
 
65
95
  # Draws the line into a graphics node.
66
96
  # @param node [Object] graphics node receiving the drawing command
67
97
  # @return [Object] graphics node command result
68
98
  def draw!(node, **) = node.LineTo(x1: position.x, y1: position.y, x2: ending.x, y2: ending.y, **)
69
99
 
100
+ private :draw!
101
+
70
102
  # Reports whether a point lies on the finite line segment.
71
103
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
72
104
  # @return [Boolean]
@@ -78,12 +110,25 @@ module Sevgi
78
110
  end
79
111
 
80
112
  # Returns a parallel line shifted by a signed perpendicular offset.
113
+ # Positive distance moves to the directed line's left in screen coordinates. Reversing the endpoints reverses the
114
+ # shift direction.
81
115
  # @param distance [Numeric] signed perpendicular offset
82
116
  # @return [Sevgi::Geometry::Line]
83
- def shift(distance) = translate(distance * F.sin(angle), -distance * F.cos(angle))
117
+ # @raise [Sevgi::Geometry::Error] when distance is not a finite real number
118
+ def shift(distance)
119
+ distance = Real[:distance, distance]
120
+ translate(distance * F.sin(angle), -distance * F.cos(angle))
121
+ end
84
122
 
85
123
  private
86
124
 
125
+ def delta(from, to) = [to.x - from.x, to.y - from.y]
126
+
127
+ def side(point)
128
+ point = Tuple[Point, point]
129
+ Cross[*delta(starting, ending), *delta(starting, point)]
130
+ end
131
+
87
132
  def within_range?(point)
88
133
  point = point.approx
89
134
  points = [starting.approx, ending.approx]
@@ -7,53 +7,132 @@ module Sevgi
7
7
  ParallelogramBase = Element.lined(4)
8
8
  private_constant :ParallelogramBase
9
9
 
10
- # Closed four-sided element built from horizontal and vertical segments.
10
+ # Closed four-sided element whose opposite sides are equal and parallel. Every construction path rejects
11
+ # degenerate or unrelated side pairs. Affine operations preserve the class while that invariant holds.
12
+ # @!method self.call(*points)
13
+ # Builds a parallelogram from four boundary points.
14
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] four boundary points
15
+ # @return [Sevgi::Geometry::Parallelogram]
16
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a parallelogram
17
+ # @!method self.from_segments(base, side, position: Origin)
18
+ # Builds a parallelogram from two adjacent segments and derives their opposites.
19
+ # @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
20
+ # @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
21
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
22
+ # @return [Sevgi::Geometry::Parallelogram]
23
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a parallelogram
24
+ # @!method self.from_points(*points)
25
+ # Builds a parallelogram from four boundary points.
26
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] four boundary points
27
+ # @return [Sevgi::Geometry::Parallelogram]
28
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a parallelogram
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] D
36
+ # @return [Sevgi::Geometry::Point] fourth vertex
37
+ # @!attribute [r] AB
38
+ # @return [Sevgi::Geometry::Line] side from A to B
39
+ # @!attribute [r] BC
40
+ # @return [Sevgi::Geometry::Line] side from B to C
41
+ # @!attribute [r] CD
42
+ # @return [Sevgi::Geometry::Line] side from C to D
43
+ # @!attribute [r] DA
44
+ # @return [Sevgi::Geometry::Line] side from D to A
45
+ # @!method perimeter
46
+ # Returns the closed path perimeter.
47
+ # @return [Float]
48
+ # @example Pair mathematical notation with English conveniences
49
+ # Sevgi::Geometry::Parallelogram[[2, 0], [2, -90]] ==
50
+ # Sevgi::Geometry::Parallelogram.from_segments([2, 0], [2, -90])
51
+ # points = [[0, 0], [2, 0], [2, 2], [0, 2]]
52
+ # Sevgi::Geometry::Parallelogram.(*points) == Sevgi::Geometry::Parallelogram.from_points(*points)
53
+ # @example Compare vertices with the axis-aligned bounding box
54
+ # shape = Sevgi::Geometry::Parallelogram.([1, 1], [5, 1], [7, 4], [3, 4])
55
+ # shape.C.deconstruct # => [7.0, 4.0]
56
+ # shape.box.width # => 6.0
57
+ # shape.box.height # => 3.0
11
58
  class Parallelogram < ParallelogramBase
12
- # Builds a parallelogram from adjacent horizontal and vertical segments.
13
- # @param horizontal [Sevgi::Geometry::Segment, Array<Numeric>] horizontal segment
14
- # @param vertical [Sevgi::Geometry::Segment, Array<Numeric>] vertical segment
59
+ # Builds a parallelogram from adjacent base and side segments. Both segments originate at `position`. `base`
60
+ # defines AB and `side` defines AD, regardless of their angles.
61
+ # @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
62
+ # @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
15
63
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
16
64
  # @return [Sevgi::Geometry::Parallelogram]
17
65
  # @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
18
- def self.[](horizontal, vertical, position: Origin)
19
- horizontal, vertical = Tuples[Segment, horizontal, vertical]
66
+ def self.[](base, side, position: Origin)
67
+ base, side = Tuples[Segment, base, side]
20
68
 
21
- new_by_segments(horizontal, vertical.reverse, horizontal.reverse, vertical, position:)
69
+ new_by_segments(base, side.reverse, base.reverse, side, position:)
22
70
  end
23
71
 
24
- # Builds a parallelogram from a horizontal segment and tallness constraint.
25
- # @param horizontal [Sevgi::Geometry::Segment, Array<Numeric>] horizontal segment
26
- # @param tallness [Sevgi::Geometry::Polar, Array<Numeric>] target tallness as length and angle
72
+ # Builds a parallelogram from a base and bounding-height constraint. The constraint length is the target height.
73
+ # its signed angle is retained as the direction of the derived side while the component magnitude determines that
74
+ # side's non-negative length.
75
+ # @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
76
+ # @param constraint [Sevgi::Geometry::LengthAngle, Array<Numeric>] target height and side direction
27
77
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
28
78
  # @return [Sevgi::Geometry::Parallelogram]
29
- # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced
30
- def self.new_by_height(horizontal:, tallness:, position: Origin)
31
- horizontal = Tuple[Segment, horizontal]
32
- tallness = Tuple[Polar, tallness]
79
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or the height constraint is infeasible
80
+ # @example Use an array constraint
81
+ # Sevgi::Geometry::Parallelogram.new_by_height(base: [4, 0], constraint: [3, -90])
82
+ # @example Use a LengthAngle constraint
83
+ # constraint = Sevgi::Geometry::LengthAngle.new(length: 3, angle: 90)
84
+ # base = Sevgi::Geometry::Segment[4, 0]
85
+ # Sevgi::Geometry::Parallelogram.new_by_height(base:, constraint:)
86
+ def self.new_by_height(base:, constraint:, position: Origin)
87
+ base = Tuple[Segment, base]
88
+ constraint = Tuple[LengthAngle, constraint]
33
89
 
34
- height = tallness.length - horizontal.y.abs
35
- angle = tallness.angle
36
- length = height / F.sin(angle)
90
+ height = constraint.length - base.y.abs
91
+ angle = constraint.angle
92
+ sine = F.sin(angle)
93
+ Error.("Parallelogram height is smaller than its base span") if height.negative?
94
+ Error.("Parallelogram height constraint must have a vertical component") if F.zero?(sine)
37
95
 
38
- self[horizontal, Segment[length, angle], position:]
96
+ self[base, Segment[height / sine.abs, angle], position:]
39
97
  end
40
98
 
41
- # Builds a parallelogram from a vertical segment and wideness constraint.
42
- # @param vertical [Sevgi::Geometry::Segment, Array<Numeric>] vertical segment
43
- # @param wideness [Sevgi::Geometry::Polar, Array<Numeric>] target wideness as length and angle
99
+ # Builds a parallelogram from a side and bounding-width constraint. The constraint length is the target width. Its
100
+ # signed angle is retained as the direction of the derived base while the component magnitude determines that
101
+ # base's non-negative length.
102
+ # @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
103
+ # @param constraint [Sevgi::Geometry::LengthAngle, Array<Numeric>] target width and base direction
44
104
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
45
105
  # @return [Sevgi::Geometry::Parallelogram]
46
- # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced
47
- def self.new_by_width(vertical:, wideness:, position: Origin)
48
- vertical = Tuple[Segment, vertical]
49
- wideness = Tuple[Polar, wideness]
106
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or the width constraint is infeasible
107
+ # @example Use an array constraint
108
+ # Sevgi::Geometry::Parallelogram.new_by_width(side: [3, 90], constraint: [4, 180])
109
+ # @example Use a LengthAngle constraint
110
+ # constraint = Sevgi::Geometry::LengthAngle.new(length: 4, angle: 0)
111
+ # side = Sevgi::Geometry::Segment[3, 90]
112
+ # Sevgi::Geometry::Parallelogram.new_by_width(side:, constraint:)
113
+ def self.new_by_width(side:, constraint:, position: Origin)
114
+ side = Tuple[Segment, side]
115
+ constraint = Tuple[LengthAngle, constraint]
50
116
 
51
- width = wideness.length - vertical.x.abs
52
- angle = wideness.angle
53
- length = width / F.cos(angle)
117
+ width = constraint.length - side.x.abs
118
+ angle = constraint.angle
119
+ cosine = F.cos(angle)
120
+ Error.("Parallelogram width is smaller than its side span") if width.negative?
121
+ Error.("Parallelogram width constraint must have a horizontal component") if F.zero?(cosine)
54
122
 
55
- self[Segment[length, angle], vertical, position:]
123
+ self[Segment[width / cosine.abs, angle], side, position:]
56
124
  end
125
+
126
+ private
127
+
128
+ def validate_geometry!
129
+ a, b, c, d = segments
130
+ valid = opposite?(a, c) && opposite?(b, d) && !F.zero?(Cross[a.x, a.y, b.x, b.y])
131
+
132
+ Error.("Parallelogram sides must be non-degenerate opposite pairs") unless valid
133
+ end
134
+
135
+ def opposite?(a, b) = F.zero?(a.x + b.x) && F.zero?(a.y + b.y)
57
136
  end
58
137
  end
59
138
  end
@@ -7,8 +7,48 @@ module Sevgi
7
7
  PolygonBase = Element.lined
8
8
  private_constant :PolygonBase
9
9
 
10
- # Variable-size closed lined element.
10
+ # Variable-size closed lined element with at least three vertices.
11
+ # @!method self.[](*segments, position: Origin)
12
+ # Builds a polygon from boundary segments.
13
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
14
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
15
+ # @return [Sevgi::Geometry::Polygon]
16
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polygon
17
+ # @!method self.call(*points)
18
+ # Builds a polygon from boundary points.
19
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points without a repeated closing point
20
+ # @return [Sevgi::Geometry::Polygon]
21
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polygon
22
+ # @!method self.from_segments(*segments, position: Origin)
23
+ # Builds a polygon from boundary segments.
24
+ # @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
25
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
26
+ # @return [Sevgi::Geometry::Polygon]
27
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polygon
28
+ # @!method self.from_points(*points)
29
+ # Builds a polygon from boundary points.
30
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points without a repeated closing point
31
+ # @return [Sevgi::Geometry::Polygon]
32
+ # @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polygon
33
+ # @!method perimeter
34
+ # Returns the closed path perimeter.
35
+ # @return [Float]
36
+ # @example Pair point notation with its English convenience
37
+ # points = [[0, 0], [2, 0], [1, 1]]
38
+ # segments = Sevgi::Geometry::Polygon.(*points).segments
39
+ # Sevgi::Geometry::Polygon[*segments] == Sevgi::Geometry::Polygon.from_segments(*segments)
40
+ # Sevgi::Geometry::Polygon.(*points) == Sevgi::Geometry::Polygon.from_points(*points)
41
+ # @example Classify points against a closed boundary
42
+ # polygon = Sevgi::Geometry::Polygon.([0, 0], [6, 0], [3, 4])
43
+ # polygon.inside?([3, 2]) # => true
44
+ # polygon.on?([3, 0]) # => true
45
+ # polygon.outside?([7, 2]) # => true
11
46
  class Polygon < PolygonBase
47
+ private
48
+
49
+ def validate_geometry!
50
+ Error.("Polygon requires at least three vertices") if points.size < 4
51
+ end
12
52
  end
13
53
  end
14
54
  end