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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Geometry
5
+ module Operation
6
+ # Returns the smallest axis-aligned rectangle enclosing all elements.
7
+ #
8
+ # Each element contributes its existing {Sevgi::Geometry::Element#box},
9
+ # including zero-size boxes whose positions may still extend the result.
10
+ # @param elements [Array<Sevgi::Geometry::Element>] elements to enclose
11
+ # @return [Sevgi::Geometry::Rect] aggregate bounding rectangle
12
+ # @raise [Sevgi::ArgumentError] when no elements are given
13
+ # @raise [Sevgi::Geometry::Operation::OperationInapplicableError] when an argument is not a geometry element
14
+ # @example Enclose several geometry values
15
+ # a = Sevgi::Geometry::Rect[10, 5, position: [2, 3]]
16
+ # b = Sevgi::Geometry::Line.([-4, 8], [20, 12])
17
+ # Sevgi::Geometry::Operation.box(a, b) # => Rect spanning both elements
18
+ def box(*elements)
19
+ validate_box_elements(elements)
20
+ boxes = elements.map(&:box)
21
+
22
+ Rect.from_corners(minimum_corner(boxes), maximum_corner(boxes))
23
+ end
24
+
25
+ private
26
+
27
+ def maximum_corner(boxes)
28
+ [
29
+ boxes.map { it.position.x + it.width }.max,
30
+ boxes.map { it.position.y + it.height }.max
31
+ ]
32
+ end
33
+
34
+ def minimum_corner(boxes)
35
+ [
36
+ boxes.map { it.position.x }.min,
37
+ boxes.map { it.position.y }.min
38
+ ]
39
+ end
40
+
41
+ def validate_box_elements(elements)
42
+ ArgumentError.("At least one geometric element required") if elements.empty?
43
+
44
+ elements.each do |element|
45
+ OperationInapplicableError.("Not a Geometric Element: #{element}") unless element.is_a?(Element)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -4,17 +4,18 @@ module Sevgi
4
4
  module Geometry
5
5
  module Operation
6
6
  # Sweep operation implementation.
7
+ # @api private
7
8
  module Sweep
8
9
  extend self
9
10
 
10
11
  # Default maximum number of sweep iterations.
11
12
  LIMIT = 1_000
12
13
 
13
- # Sweeps parallel lines across a lined element in both directions.
14
+ # Sweeps parallel lines across a geometry element in both directions.
14
15
  #
15
16
  # Generated lines are boundary-to-boundary interior spans. A single
16
- # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
17
- # @param element [Sevgi::Geometry::Element::Lined] element to intersect
17
+ # sweep position can produce multiple lines for closed concave elements. Open paths produce no interior lines.
18
+ # @param element [Sevgi::Geometry::Element] element to intersect
18
19
  # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
19
20
  # @param angle [Numeric] clockwise sweep line angle in degrees
20
21
  # @param step [Numeric] signed distance between sweep lines
@@ -23,9 +24,10 @@ module Sevgi
23
24
  # @yieldparam lines [Array<Sevgi::Geometry::Line>] generated sweep lines
24
25
  # @yieldreturn [void]
25
26
  # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
26
- # @raise [Sevgi::Geometry::Error] when initial cannot be coerced
27
+ # @raise [Sevgi::Geometry::Error] when initial, angle, step, or limit is invalid
27
28
  # @raise [Sevgi::Geometry::Operation::OperationError] when iteration reaches the limit
28
29
  def sweep(element, initial:, angle:, step:, limit: LIMIT, &block)
30
+ step = validate_arguments(step, limit)
29
31
  equation = Tuple[Point, initial].equation(angle)
30
32
 
31
33
  [
@@ -36,11 +38,11 @@ module Sevgi
36
38
  end
37
39
  end
38
40
 
39
- # Sweeps parallel lines across a lined element and requires at least one result.
41
+ # Sweeps parallel lines across a geometry element and requires at least one result.
40
42
  #
41
43
  # Generated lines are boundary-to-boundary interior spans. A single
42
- # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
43
- # @param element [Sevgi::Geometry::Element::Lined] element to intersect
44
+ # sweep position can produce multiple lines for closed concave elements. Open paths produce no interior lines.
45
+ # @param element [Sevgi::Geometry::Element] element to intersect
44
46
  # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
45
47
  # @param angle [Numeric] clockwise sweep line angle in degrees
46
48
  # @param step [Numeric] signed distance between sweep lines
@@ -49,7 +51,7 @@ module Sevgi
49
51
  # @yieldparam lines [Array<Sevgi::Geometry::Line>] generated sweep lines
50
52
  # @yieldreturn [void]
51
53
  # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
52
- # @raise [Sevgi::Geometry::Error] when initial cannot be coerced
54
+ # @raise [Sevgi::Geometry::Error] when initial, angle, step, or limit is invalid
53
55
  # @raise [Sevgi::Geometry::Operation::OperationError] when no lines are found or iteration reaches the limit
54
56
  def sweep!(element, initial:, angle:, step:, limit: LIMIT, &block)
55
57
  sweep(element, initial:, angle:, step:, limit:) do |lines|
@@ -64,14 +66,16 @@ module Sevgi
64
66
  # Sweeps parallel lines in one signed direction from an equation.
65
67
  #
66
68
  # Generated lines are boundary-to-boundary interior spans. A single
67
- # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
68
- # @param element [Sevgi::Geometry::Element::Lined] element to intersect
69
+ # sweep position can produce multiple lines for closed concave elements. Open paths produce no interior lines.
70
+ # @param element [Sevgi::Geometry::Element] element to intersect
69
71
  # @param equation [Sevgi::Geometry::Equation] initial sweep equation
70
72
  # @param step [Numeric] signed distance between sweep lines
71
73
  # @param limit [Integer] maximum iterations
72
74
  # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
75
+ # @raise [Sevgi::Geometry::Error] when step or limit is invalid
73
76
  # @raise [Sevgi::Geometry::Operation::OperationError] when iteration reaches the limit
74
77
  def unisweep(element, equation, step, limit: LIMIT)
78
+ step = validate_arguments(step, limit)
75
79
  lines = []
76
80
 
77
81
  limit.times do
@@ -86,36 +90,46 @@ module Sevgi
86
90
  OperationError.("Loop limit reached: #{limit}")
87
91
  end
88
92
 
93
+ private :unisweep
94
+
89
95
  # Reports whether the sweep handler can operate on an element.
90
96
  # @api private
91
97
  # @param element [Object] candidate element
92
98
  # @return [Boolean]
93
99
  def applicable?(element)
94
- element.respond_to?(:intersection)
100
+ element.respond_to?(:intersection) && element.respond_to?(:inside?) && element.respond_to?(:on?)
95
101
  end
96
102
 
97
103
  private
98
104
 
105
+ def validate_arguments(step, limit)
106
+ step = Real[:step, step]
107
+ Error.("Sweep step must be nonzero") if step.zero?
108
+ unless limit.is_a?(::Integer) && limit.positive?
109
+ Error.("Sweep limit must be a positive Integer: #{limit.inspect}")
110
+ end
111
+
112
+ step
113
+ end
114
+
99
115
  def interior_lines(element, equation, points)
100
- return [] if element.class.respond_to?(:open?) && element.class.open?
116
+ return [] unless element.closed?
101
117
 
102
118
  if points.size == 2
119
+ return [] unless element.inside?(Point.midpoint(*points))
120
+
103
121
  line = simple_line(points)
104
122
 
105
123
  return line ? [line] : []
106
124
  end
107
125
 
108
126
  sorted_points(equation, points).each_cons(2).filter_map do |starting, ending|
109
- next unless element.inside?(midpoint(starting, ending))
127
+ next unless element.inside?(Point.midpoint(starting, ending))
110
128
 
111
129
  simple_line([starting, ending])
112
130
  end
113
131
  end
114
132
 
115
- def midpoint(starting, ending)
116
- Point[(starting.x + ending.x) / 2.0, (starting.y + ending.y) / 2.0]
117
- end
118
-
119
133
  def simple_line(points)
120
134
  line = Line.(*points)
121
135
 
@@ -129,7 +143,7 @@ module Sevgi
129
143
  end
130
144
  end
131
145
 
132
- register(Sweep, :sweep, :sweep!, :unisweep)
146
+ register(Sweep, :sweep, :sweep!)
133
147
 
134
148
  private_constant :Sweep
135
149
  end
@@ -2,7 +2,13 @@
2
2
 
3
3
  module Sevgi
4
4
  module Geometry
5
- # Dispatches geometry operations to operation handler modules.
5
+ # Stateless operations that relate or derive geometry values.
6
+ #
7
+ # `alignment` returns a translation offset, while `align` applies that
8
+ # offset to a copy. Center alignment works on both axes. Edge alignments
9
+ # change only the named axis and preserve the other coordinate. `sweep`
10
+ # derives boundary-to-boundary spans from a closed geometry element, and
11
+ # `sweep!` additionally requires at least one span.
6
12
  module Operation
7
13
  extend self
8
14
 
@@ -15,25 +21,37 @@ module Sevgi
15
21
  # @!parse
16
22
  # class << self
17
23
  # # Returns an element translated to align with another element.
24
+ # # Center alignment changes both axes. Edge alignments change only the named axis.
18
25
  # # @param element [Sevgi::Geometry::Element] element to move
19
26
  # # @param other [Sevgi::Geometry::Element] reference element
20
27
  # # @param alignment [Symbol] one of :center, :left, :right, :top, or :bottom
21
28
  # # @return [Sevgi::Geometry::Element] translated element
22
29
  # # @raise [Sevgi::Geometry::Operation::OperationInapplicableError] when an argument is not a geometry element
23
30
  # # @raise [Sevgi::ArgumentError] when alignment is unknown
31
+ # # @example Center one rectangle inside another
32
+ # # inner = Sevgi::Geometry::Rect[4, 2]
33
+ # # outer = Sevgi::Geometry::Rect[20, 10, position: [5, 5]]
34
+ # # Sevgi::Geometry::Operation.align(inner, outer).position.deconstruct # => [13.0, 9.0]
24
35
  # def align(element, other, alignment = :center); end
25
36
  #
26
37
  # # Returns the offset needed to align one element with another.
38
+ # # Center alignment includes both axes. Edge alignments return zero on the other axis.
27
39
  # # @param element [Sevgi::Geometry::Element] element to move
28
40
  # # @param other [Sevgi::Geometry::Element] reference element
29
41
  # # @param alignment [Symbol] one of :center, :left, :right, :top, or :bottom
30
42
  # # @return [Sevgi::Geometry::Point] translation offset
31
43
  # # @raise [Sevgi::Geometry::Operation::OperationInapplicableError] when an argument is not a geometry element
32
44
  # # @raise [Sevgi::ArgumentError] when alignment is unknown
45
+ # # @example Calculate an offset without moving the element
46
+ # # inner = Sevgi::Geometry::Rect[4, 2]
47
+ # # outer = Sevgi::Geometry::Rect[20, 10, position: [5, 5]]
48
+ # # Sevgi::Geometry::Operation.alignment(inner, outer, :bottom).approx.deconstruct # => [0.0, 13.0]
33
49
  # def alignment(element, other, alignment = :center); end
34
50
  #
35
- # # Sweeps parallel lines across a lined element in both directions.
36
- # # @param element [Sevgi::Geometry::Element::Lined] element to intersect
51
+ # # Sweeps parallel lines across a geometry element in both directions.
52
+ # # `angle` is the direction of the returned lines. `step` is their signed perpendicular spacing.
53
+ # # Open paths yield no interior spans.
54
+ # # @param element [Sevgi::Geometry::Element] element to intersect
37
55
  # # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
38
56
  # # @param angle [Numeric] clockwise sweep line angle in degrees
39
57
  # # @param step [Numeric] signed distance between sweep lines
@@ -43,12 +61,18 @@ module Sevgi
43
61
  # # @yieldreturn [void]
44
62
  # # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
45
63
  # # @raise [Sevgi::Geometry::Operation::OperationInapplicableError] when element is not sweepable
46
- # # @raise [Sevgi::Geometry::Error] when initial cannot be coerced
64
+ # # @raise [Sevgi::Geometry::Error] when initial, angle, step, or limit is invalid
47
65
  # # @raise [Sevgi::Geometry::Operation::OperationError] when iteration reaches the limit
66
+ # # @example Generate horizontal spans through a rectangle
67
+ # # rect = Sevgi::Geometry::Rect[10, 6]
68
+ # # lines = Sevgi::Geometry::Operation.sweep(rect, initial: [0, 0], angle: 0, step: 2)
69
+ # # lines.size # => 4
70
+ # # lines.map(&:length).uniq # => [10.0]
48
71
  # def sweep(element, initial:, angle:, step:, limit: Sweep::LIMIT); end
49
72
  #
50
- # # Sweeps parallel lines across a lined element and requires at least one result.
51
- # # @param element [Sevgi::Geometry::Element::Lined] element to intersect
73
+ # # Sweeps parallel lines across a geometry element and requires at least one result.
74
+ # # It has the same geometry as {sweep}, but raises when the result is empty.
75
+ # # @param element [Sevgi::Geometry::Element] element to intersect
52
76
  # # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
53
77
  # # @param angle [Numeric] clockwise sweep line angle in degrees
54
78
  # # @param step [Numeric] signed distance between sweep lines
@@ -58,19 +82,9 @@ module Sevgi
58
82
  # # @yieldreturn [void]
59
83
  # # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
60
84
  # # @raise [Sevgi::Geometry::Operation::OperationInapplicableError] when element is not sweepable
61
- # # @raise [Sevgi::Geometry::Error] when initial cannot be coerced
85
+ # # @raise [Sevgi::Geometry::Error] when initial, angle, step, or limit is invalid
62
86
  # # @raise [Sevgi::Geometry::Operation::OperationError] when no lines are found or iteration reaches the limit
63
87
  # def sweep!(element, initial:, angle:, step:, limit: Sweep::LIMIT); end
64
- #
65
- # # Sweeps parallel lines in one signed direction from an equation.
66
- # # @param element [Sevgi::Geometry::Element::Lined] element to intersect
67
- # # @param equation [Sevgi::Geometry::Equation] initial sweep equation
68
- # # @param step [Numeric] signed distance between sweep lines
69
- # # @param limit [Integer] maximum iterations
70
- # # @return [Array<Sevgi::Geometry::Line>] generated sweep lines
71
- # # @raise [Sevgi::Geometry::Operation::OperationInapplicableError] when element is not sweepable
72
- # # @raise [Sevgi::Geometry::Operation::OperationError] when iteration reaches the limit
73
- # def unisweep(element, equation, step, limit: Sweep::LIMIT); end
74
88
  # end
75
89
  # Registers one or more public operation methods.
76
90
  # @api private
@@ -87,7 +101,7 @@ module Sevgi
87
101
  define_singleton_method(operation) do |element, *args, **kwargs, &block|
88
102
  OperationInapplicableError.("Not a Geometric Element: #{element}") unless element.is_a?(Element)
89
103
  unless handler.applicable?(element)
90
- OperationInapplicableError.("Unapplicable operation for #{element}: #{handler}")
104
+ OperationInapplicableError.("Operation not applicable to #{element}: #{handler}")
91
105
  end
92
106
 
93
107
  handler.public_send(operation, element, *args, **kwargs, &block)
@@ -98,4 +112,5 @@ module Sevgi
98
112
  end
99
113
 
100
114
  require_relative "operation/align"
115
+ require_relative "operation/box"
101
116
  require_relative "operation/sweep"
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Sevgi
4
4
  module Geometry
5
- # Affine transforms shared by immutable geometry tuples.
5
+ # Implementation owner and registry for public affine transformations on Point and lined elements.
6
+ # @api private
6
7
  module Affinity
7
8
  # Reflects a point across the selected axes.
8
9
  #
@@ -11,45 +12,141 @@ module Sevgi
11
12
  # @param x [Boolean] reflect across the x-axis
12
13
  # @param y [Boolean] reflect across the y-axis
13
14
  # @return [Sevgi::Geometry::Point]
14
- def reflect(x: true, y: true) = with(x: (y ? -1 : 1) * self.x(), y: (x ? -1 : 1) * self.y())
15
+ # @raise [Sevgi::Geometry::Error] when a flag is not Boolean
16
+ def reflect(x: true, y: true)
17
+ Error.("Reflection x flag must be Boolean") unless x.equal?(true) || x.equal?(false)
18
+ Error.("Reflection y flag must be Boolean") unless y.equal?(true) || y.equal?(false)
19
+
20
+ with(x: (y ? -1 : 1) * self.x(), y: (x ? -1 : 1) * self.y())
21
+ end
15
22
 
16
23
  # Rotates a point around the origin using screen-space degrees.
17
24
  # @param a [Numeric] clockwise angle in degrees
18
25
  # @return [Sevgi::Geometry::Point]
19
- def rotate(a) = with(x: (x * F.cos(a)) - (y * F.sin(a)), y: (x * F.sin(a)) + (y * F.cos(a)))
26
+ # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
27
+ def rotate(a)
28
+ a = Real[:angle, a]
29
+ with(x: (x * F.cos(a)) - (y * F.sin(a)), y: (x * F.sin(a)) + (y * F.cos(a)))
30
+ end
20
31
 
21
32
  # Scales a point from the origin.
22
33
  # @param sx [Numeric] x scale factor
23
34
  # @param sy [Numeric, Sevgi::Undefined] y scale factor, defaulting to sx
24
35
  # @return [Sevgi::Geometry::Point]
25
- def scale(sx, sy = Undefined) = with(x: sx * x, y: Undefined.default(sy, sx) * y)
36
+ # @raise [Sevgi::Geometry::Error] when a scale is not a finite real number
37
+ def scale(sx, sy = Undefined)
38
+ sx = Real[:sx, sx]
39
+ sy = Real[:sy, Undefined.default(sy, sx)]
40
+ with(x: sx * x, y: sy * y)
41
+ end
26
42
 
27
43
  # Skews a point from the origin.
28
44
  # @param ax [Numeric] x-axis skew angle in degrees
29
45
  # @param ay [Numeric, Sevgi::Undefined] y-axis skew angle in degrees, defaulting to ax
30
46
  # @return [Sevgi::Geometry::Point]
31
- def skew(ax, ay = Undefined) = with(x: x + (y * F.tan(ax)), y: y + (x * F.tan(Undefined.default(ay, ax))))
47
+ # @raise [Sevgi::Geometry::Error] when an angle is not a finite real number
48
+ def skew(ax, ay = Undefined)
49
+ ax = Real[:ax, ax]
50
+ ay = Real[:ay, Undefined.default(ay, ax)]
51
+ with(x: x + (y * F.tan(ax)), y: y + (x * F.tan(ay)))
52
+ end
32
53
 
33
54
  # Skews a point along x.
34
55
  # @param a [Numeric] skew angle in degrees
35
56
  # @return [Sevgi::Geometry::Point]
36
- def skew_x(a) = with(x: x + (y * F.tan(a)))
57
+ # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
58
+ def skew_x(a) = with(x: x + (y * F.tan(Real[:angle, a])))
37
59
 
38
60
  # Skews a point along y.
39
61
  # @param a [Numeric] skew angle in degrees
40
62
  # @return [Sevgi::Geometry::Point]
41
- def skew_y(a) = with(y: y + (x * F.tan(a)))
63
+ # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
64
+ def skew_y(a) = with(y: y + (x * F.tan(Real[:angle, a])))
42
65
 
43
66
  # Translates a point.
44
67
  # @param dx [Numeric] x offset
45
68
  # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
46
69
  # @return [Sevgi::Geometry::Point]
47
- def translate(dx, dy = Undefined) = with(x: x + dx, y: y + Undefined.default(dy, dx))
70
+ # @raise [Sevgi::Geometry::Error] when an offset is not a finite real number
71
+ def translate(dx, dy = Undefined)
72
+ dx = Real[:dx, dx]
73
+ dy = Real[:dy, Undefined.default(dy, dx)]
74
+ with(x: x + dx, y: y + dy)
75
+ end
48
76
  end
49
77
 
50
78
  # Immutable point in SVG/screen coordinates.
51
79
  #
52
- # Use `Point[x, y]` to create a point from two coordinates.
80
+ # Use `Point[x, y]` to create a point from two coordinates. Public geometry
81
+ # methods that expect a point also accept `[x, y]`. Explicit Point values are
82
+ # most useful when a result will be transformed, compared, or reused.
83
+ # @example Measure and rotate a point in screen coordinates
84
+ # point = Sevgi::Geometry::Point[3, 4]
85
+ # Sevgi::Geometry::Point.length(Sevgi::Geometry::Origin, point) # => 5.0
86
+ # point.rotate(90).approx.deconstruct # => [-4.0, 3.0]
87
+ # @example Compare positions in screen coordinates
88
+ # upper = Sevgi::Geometry::Point[4, 2]
89
+ # lower = Sevgi::Geometry::Point[4, 8]
90
+ # upper.above?(lower) # => true
91
+ # upper.left?([6, 2]) # => true
92
+ # @see Sevgi::Geometry::Segment
93
+ # @!parse
94
+ # class Point
95
+ # # Creates a point from two coordinates.
96
+ # # @param x [Numeric] x coordinate
97
+ # # @param y [Numeric] y coordinate
98
+ # # @return [Sevgi::Geometry::Point]
99
+ # # @raise [Sevgi::Geometry::Error] when a coordinate is not a finite Numeric
100
+ # # @example Create a point with mathematical notation
101
+ # # Sevgi::Geometry::Point[3, 5]
102
+ # def self.[](x, y); end
103
+ #
104
+ # # Returns a point reflected across the selected axes.
105
+ # # @param x [Boolean] reflect across the x-axis
106
+ # # @param y [Boolean] reflect across the y-axis
107
+ # # @return [Sevgi::Geometry::Point]
108
+ # # @raise [Sevgi::Geometry::Error] when a flag is not Boolean
109
+ # def reflect(x: true, y: true); end
110
+ #
111
+ # # Returns a point rotated around the origin.
112
+ # # @param a [Numeric] clockwise angle in degrees
113
+ # # @return [Sevgi::Geometry::Point]
114
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
115
+ # def rotate(a); end
116
+ #
117
+ # # Returns a point scaled from the origin.
118
+ # # @param sx [Numeric] x scale factor
119
+ # # @param sy [Numeric, Sevgi::Undefined] y scale factor, defaulting to sx
120
+ # # @return [Sevgi::Geometry::Point]
121
+ # # @raise [Sevgi::Geometry::Error] when a scale is not a finite real number
122
+ # def scale(sx, sy = Undefined); end
123
+ #
124
+ # # Returns a point skewed from the origin.
125
+ # # @param ax [Numeric] x-axis skew angle in degrees
126
+ # # @param ay [Numeric, Sevgi::Undefined] y-axis skew angle in degrees, defaulting to ax
127
+ # # @return [Sevgi::Geometry::Point]
128
+ # # @raise [Sevgi::Geometry::Error] when an angle is not a finite real number
129
+ # def skew(ax, ay = Undefined); end
130
+ #
131
+ # # Returns a point skewed along x.
132
+ # # @param a [Numeric] skew angle in degrees
133
+ # # @return [Sevgi::Geometry::Point]
134
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
135
+ # def skew_x(a); end
136
+ #
137
+ # # Returns a point skewed along y.
138
+ # # @param a [Numeric] skew angle in degrees
139
+ # # @return [Sevgi::Geometry::Point]
140
+ # # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
141
+ # def skew_y(a); end
142
+ #
143
+ # # Returns a translated point.
144
+ # # @param dx [Numeric] x offset
145
+ # # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
146
+ # # @return [Sevgi::Geometry::Point]
147
+ # # @raise [Sevgi::Geometry::Error] when an offset is not a finite real number
148
+ # def translate(dx, dy = Undefined); end
149
+ # end
53
150
  Point = Data.define(:x, :y) do
54
151
  include Comparable
55
152
  include Affinity
@@ -87,7 +184,17 @@ module Sevgi
87
184
  # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
88
185
  def self.length(starting, ending)
89
186
  starting, ending = Tuples[Point, starting, ending]
90
- ::Math.sqrt(((starting.y - ending.y) ** 2) + ((starting.x - ending.x) ** 2))
187
+ ::Math.hypot(starting.x - ending.x, starting.y - ending.y)
188
+ end
189
+
190
+ # Returns the midpoint between two points.
191
+ # @param starting [Sevgi::Geometry::Point, Array<Numeric>] first point
192
+ # @param ending [Sevgi::Geometry::Point, Array<Numeric>] second point
193
+ # @return [Sevgi::Geometry::Point] midpoint
194
+ # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
195
+ def self.midpoint(starting, ending)
196
+ starting, ending = Tuples[Point, starting, ending]
197
+ self[(starting.x / 2.0) + (ending.x / 2.0), (starting.y / 2.0) + (ending.y / 2.0)]
91
198
  end
92
199
 
93
200
  # Returns the origin point.
@@ -104,10 +211,13 @@ module Sevgi
104
211
  def initialize(x:, y:) = super(x: Real[:x, x], y: Real[:y, y])
105
212
 
106
213
  # Compares points by x, then y.
107
- # @param other [Sevgi::Geometry::Point, Array<Numeric>] point to compare
108
- # @return [Integer, nil]
109
- # @raise [Sevgi::Geometry::Error] when other cannot be coerced
110
- def <=>(other) = deconstruct <=> Tuple[Point, other].deconstruct
214
+ # @param other [Object] point or two-item coordinate array to compare
215
+ # @return [Integer, nil] comparison result, or nil when other is not a valid point value
216
+ def <=>(other)
217
+ deconstruct <=> Tuple[Point, other].deconstruct
218
+ rescue Error
219
+ nil
220
+ end
111
221
 
112
222
  # Reports whether this point is at or above another point in screen coordinates.
113
223
  # @param other [Sevgi::Geometry::Point, Array<Numeric>] point to compare
@@ -165,6 +275,8 @@ module Sevgi
165
275
  alias_method :==, :eql?
166
276
  end
167
277
 
278
+ private_constant :Affinity
279
+
168
280
  # Origin point in SVG/screen coordinates.
169
281
  Origin = Point.origin
170
282
  end