sevgi-geometry 0.95.0 → 0.98.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,32 @@ 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
+ # @!attribute [r] A
31
+ # @return [Sevgi::Geometry::Point] starting point
32
+ # @!attribute [r] B
33
+ # @return [Sevgi::Geometry::Point] ending point
34
+ # @!attribute [r] AB
35
+ # @return [Sevgi::Geometry::Line] line from A to B
11
36
  class Line < LineBase
12
37
  # @overload [](length, angle, position: Origin)
13
38
  # Builds a line from length and angle.
@@ -15,16 +40,18 @@ module Sevgi
15
40
  # @param angle [Numeric] clockwise angle in degrees
16
41
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
17
42
  # @return [Sevgi::Geometry::Line]
18
- # @raise [Sevgi::Geometry::Error] when position cannot be coerced
19
- def self.[](...) = from_length_angle(...)
43
+ # @raise [Sevgi::Geometry::Error] when length, angle, or position cannot be coerced to finite geometry values
44
+ # @example Mathematical notation and English convenience are equivalent
45
+ # Sevgi::Geometry::Line[5, 30] == Sevgi::Geometry::Line.from_length_angle(5, 30)
46
+ def self.[](length, angle, position: Origin) = new_by_segments(Segment[length, angle], position:)
20
47
 
21
48
  # Builds a line from length and angle.
22
49
  # @param length [Numeric] line length
23
50
  # @param angle [Numeric] clockwise angle in degrees
24
51
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
25
52
  # @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:)
53
+ # @raise [Sevgi::Geometry::Error] when length, angle, or position cannot be coerced to finite geometry values
54
+ def self.from_length_angle(length, angle, position: Origin) = self[length, angle, position:]
28
55
 
29
56
  # @overload from_points(starting, ending)
30
57
  # Builds a line from two endpoints.
@@ -32,7 +59,11 @@ module Sevgi
32
59
  # @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
33
60
  # @return [Sevgi::Geometry::Line]
34
61
  # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
35
- def self.from_points(...) = new_by_points(...)
62
+ # @example Mathematical notation and English convenience are equivalent
63
+ # Sevgi::Geometry::Line.([0, 0], [3, 4]) == Sevgi::Geometry::Line.from_points([0, 0], [3, 4])
64
+ def self.from_points(...) = call(...)
65
+
66
+ private_class_method :from_segments
36
67
 
37
68
  # Returns the clockwise line angle in degrees.
38
69
  # @return [Float]
@@ -42,21 +73,19 @@ module Sevgi
42
73
  # @return [Sevgi::Geometry::Point]
43
74
  def ending = points.last
44
75
 
45
- # Reports whether a point is left of the line equation.
76
+ # Reports whether a point is left of the directed line from {#starting} to {#ending} in screen coordinates.
77
+ # Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
46
78
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
47
79
  # @return [Boolean]
48
80
  # @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
81
+ def left?(point) = F.lt?(side(point), 0.0)
54
82
 
55
- # Reports whether a point is right of the line equation.
83
+ # Reports whether a point is right of the directed line from {#starting} to {#ending} in screen coordinates.
84
+ # Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
56
85
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
57
86
  # @return [Boolean]
58
87
  # @raise [Sevgi::Geometry::Error] when point cannot be coerced
59
- def right?(point) = equation.right?(point)
88
+ def right?(point) = F.gt?(side(point), 0.0)
60
89
 
61
90
  # Returns the starting point.
62
91
  # @return [Sevgi::Geometry::Point]
@@ -67,6 +96,8 @@ module Sevgi
67
96
  # @return [Object] graphics node command result
68
97
  def draw!(node, **) = node.LineTo(x1: position.x, y1: position.y, x2: ending.x, y2: ending.y, **)
69
98
 
99
+ private :draw!
100
+
70
101
  # Reports whether a point lies on the finite line segment.
71
102
  # @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
72
103
  # @return [Boolean]
@@ -78,12 +109,26 @@ module Sevgi
78
109
  end
79
110
 
80
111
  # Returns a parallel line shifted by a signed perpendicular offset.
112
+ # Positive distance moves to the directed line's left in screen coordinates; reversing the endpoints reverses the
113
+ # shift direction.
81
114
  # @param distance [Numeric] signed perpendicular offset
82
115
  # @return [Sevgi::Geometry::Line]
83
- def shift(distance) = translate(distance * F.sin(angle), -distance * F.cos(angle))
116
+ # @raise [Sevgi::Geometry::Error] when distance is not a finite real number
117
+ def shift(distance)
118
+ distance = Real[:distance, distance]
119
+ translate(distance * F.sin(angle), -distance * F.cos(angle))
120
+ end
84
121
 
85
122
  private
86
123
 
124
+ def cross(ax, ay, bx, by) = (ax * by) - (ay * bx)
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,134 @@ 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, b))
131
+
132
+ Error.("Parallelogram sides must be non-degenerate opposite pairs") unless valid
133
+ end
134
+
135
+ def cross(a, b) = (a.x * b.y) - (a.y * b.x)
136
+
137
+ def opposite?(a, b) = F.zero?(a.x + b.x) && F.zero?(a.y + b.y)
57
138
  end
58
139
  end
59
140
  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
@@ -7,8 +7,43 @@ 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
+ # @example Pair mathematical notation with English conveniences
34
+ # Sevgi::Geometry::Polyline[[2, 0], [1, 90]] == Sevgi::Geometry::Polyline.from_segments([2, 0], [1, 90])
35
+ # Sevgi::Geometry::Polyline.([0, 0], [2, 0]) == Sevgi::Geometry::Polyline.from_points([0, 0], [2, 0])
36
+ # @example Measure and query an open path
37
+ # path = Sevgi::Geometry::Polyline.([0, 0], [3, 0], [3, 4])
38
+ # path.length # => 7.0
39
+ # path.on?([2, 0]) # => true
40
+ # path.inside?([1, 1]) # => false
11
41
  class Polyline < PolylineBase
42
+ private
43
+
44
+ def validate_geometry!
45
+ Error.("Polyline requires at least two points") if points.size < 2
46
+ end
12
47
  end
13
48
  end
14
49
  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,92 @@ 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
+
67
164
  # Returns rectangle height.
68
165
  # @return [Float]
69
166
  def height = @height ||= segments[1].length
@@ -111,19 +208,52 @@ module Sevgi
111
208
  %i[top right bottom left].each_with_index do |side, i|
112
209
  define_method(side) { lines[i] }
113
210
  end
211
+
212
+ private
213
+
214
+ def validate_geometry!
215
+ left, right, top, bottom = self.class.send(:bounds, points)
216
+ expected = [[left, top], [right, top], [right, bottom], [left, bottom], [left, top]]
217
+ valid = points.zip(expected).all? { |point, pair| point.eq?(Point[*pair]) }
218
+
219
+ Error.("Rectangle points must form an axis-aligned rectangle") unless valid
220
+ end
114
221
  end
115
222
 
116
- # Rectangle with equal width and height.
223
+ # Rectangle with equal width and height. Use {#width} or {#height} for its side length; inherited
224
+ # {Element::Lined#length} returns the complete path length.
225
+ # @example Construct the same square from opposite corners
226
+ # Sevgi::Geometry::Square.([0, 0], [5, 5]) == Sevgi::Geometry::Square.from_corners([0, 0], [5, 5])
117
227
  class Square < Rect
118
- # @return [Float] side length
119
- alias length width
228
+ # Builds a square from side length and top-left position.
229
+ # @param length [Numeric] side length
230
+ # @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
231
+ # @return [Sevgi::Geometry::Square]
232
+ # @raise [Sevgi::Geometry::Error] when position cannot be coerced or length is negative
233
+ # @example Mathematical notation and English convenience are equivalent
234
+ # Sevgi::Geometry::Square[5] == Sevgi::Geometry::Square.from_size(5)
235
+ def self.[](length, position: Origin) = construct(length, length, position:)
236
+
237
+ # Builds a square from two opposite corners.
238
+ # @param top_left [Sevgi::Geometry::Point, Array<Numeric>] top-left corner
239
+ # @param bottom_right [Sevgi::Geometry::Point, Array<Numeric>] bottom-right corner
240
+ # @return [Sevgi::Geometry::Square]
241
+ # @raise [Sevgi::Geometry::Error] when points are invalid or define unequal dimensions
242
+ def self.from_corners(top_left, bottom_right) = call(top_left, bottom_right)
120
243
 
121
244
  # Builds a square from side length and top-left position.
122
245
  # @param length [Numeric] side length
123
246
  # @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
124
247
  # @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:)
248
+ # @raise [Sevgi::Geometry::Error] when position or length is invalid
249
+ def self.from_size(length, position: Origin) = self[length, position:]
250
+
251
+ private
252
+
253
+ def validate_geometry!
254
+ super
255
+ Error.("Square sides must have equal length") unless F.eq?(width, height)
256
+ end
127
257
  end
128
258
  end
129
259
  end