sevgi-geometry 0.98.2 → 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.
@@ -4,15 +4,15 @@ module Sevgi
4
4
  module Geometry
5
5
  # Abstract base class for geometry equations used in boundary intersections.
6
6
  #
7
- # The supported public factories build horizontal, vertical, and diagonal
8
- # linear equations. {#intersect} always returns an Array: no intersection is
9
- # `[]`, while one crossing is a one-item Array. Coincident parallel lines do
10
- # not represent a finite intersection and also return an empty Array.
7
+ # Public factories build linear and implicit quadratic equations.
8
+ # Linear/quadratic intersection is supported in either order. {#intersect} returns an Array: no intersection is
9
+ # `[]`, while one crossing is a one-item Array. Parallel and coincident lines return an empty Array.
10
+ # Linear equation construction and intersection do not round angles or slopes to the display precision.
11
11
  # @example Intersect two linear equations
12
12
  # diagonal = Sevgi::Geometry::Equation.diagonal(slope: 1, intercept: 0)
13
13
  # vertical = Sevgi::Geometry::Equation.vertical(3)
14
14
  # diagonal.intersect(vertical).map(&:deconstruct) # => [[3.0, 3.0]]
15
- # @see Sevgi::Geometry::Element::Lined#intersection
15
+ # @see Sevgi::Geometry::Element#intersection
16
16
  class Equation
17
17
  private_class_method :new
18
18
 
@@ -35,10 +35,18 @@ module Sevgi
35
35
  # @raise [Sevgi::Geometry::Error] when const is not a finite Numeric
36
36
  def self.vertical(const) = Linear::Vertical.new(const)
37
37
 
38
+ # Builds an implicit quadratic carrier around an optional local origin.
39
+ # Coefficients follow `a*x*x + b*x*y + c*y*y + d*x + e*y + f = 0` in coordinates relative to origin.
40
+ # @param coefficients [Array<Numeric>] six finite coefficients in a, b, c, d, e, f order
41
+ # @param origin [Sevgi::Geometry::Point, Array<Numeric>] local coordinate origin
42
+ # @return [Sevgi::Geometry::Equation::Quadratic]
43
+ # @raise [Sevgi::Geometry::Error] when coefficients or origin are invalid, or the degree is less than two
44
+ def self.quadratic(*coefficients, origin: Origin) = Quadratic.new(*coefficients, origin:)
45
+
38
46
  # Intersects this equation with another equation.
39
47
  # @param other [Sevgi::Geometry::Equation] equation to intersect with
40
48
  # @return [Array<Sevgi::Geometry::Point>] intersection points
41
- # @raise [Sevgi::Geometry::Error] when other is not an equation
49
+ # @raise [Sevgi::Geometry::Error] when other is not an equation or quadratic/quadratic intersection is requested
42
50
  # @raise [Sevgi::PanicError] when the equation combination is not implemented
43
51
  def intersect(other)
44
52
  Error.("Must be an equation: #{other}") unless other.is_a?(Equation)
@@ -48,6 +56,8 @@ module Sevgi
48
56
  linear_vs_linear(other)
49
57
  in [Linear, Quadratic]
50
58
  linear_vs_quadratic(other)
59
+ in [Quadratic, Linear]
60
+ other.intersect(self)
51
61
  in [Quadratic, Quadratic]
52
62
  quadratic_vs_quadratic(other)
53
63
  else
@@ -60,7 +70,7 @@ module Sevgi
60
70
  # Evaluates y for an x coordinate.
61
71
  # @abstract Subclasses implement equation-specific mapping.
62
72
  # @param _x [Numeric] x coordinate
63
- # @return [Float]
73
+ # @return [Float, Array<Float>] one value for a linear equation, or zero to two roots for a quadratic
64
74
  # @raise [Sevgi::PanicError] when a subclass does not implement y
65
75
  def y(_x, ...) = PanicError.("#{self.class}#y must be implemented")
66
76
 
@@ -87,7 +97,7 @@ module Sevgi
87
97
  end
88
98
 
89
99
  def nonvertical_vs_nonvertical(left, right)
90
- return nil if F.eq?(left.slope, right.slope)
100
+ return nil if left.slope == right.slope
91
101
 
92
102
  x = (right.intercept - left.intercept) / (left.slope - right.slope)
93
103
 
@@ -100,12 +110,10 @@ module Sevgi
100
110
  Point[x, nonvertical.y(x)]
101
111
  end
102
112
 
103
- def linear_vs_quadratic(...)
104
- PanicError.("Linear/quadratic intersection must be implemented")
105
- end
113
+ def linear_vs_quadratic(other) = other.send(:intersect_linear, self)
106
114
 
107
115
  def quadratic_vs_quadratic(...)
108
- PanicError.("Quadratic/quadratic intersection must be implemented")
116
+ Error.("Quadratic/quadratic intersection is not supported")
109
117
  end
110
118
  end
111
119
 
@@ -117,18 +125,28 @@ module Sevgi
117
125
  # point.equation(90).x(20) # => 4.0
118
126
  # @param angle [Numeric] clockwise angle in degrees
119
127
  # @return [Sevgi::Geometry::Equation::Linear]
128
+ # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
120
129
  def equation(angle)
121
- return Equation.horizontal(y) if F.zero?(angle % 180.0)
122
- return Equation.vertical(x) if F.zero?(angle % 90.0)
130
+ angle = Real[:angle, angle]
131
+ return Equation.horizontal(y) if (angle % 180.0).zero?
132
+ return Equation.vertical(x) if (angle % 90.0).zero?
123
133
 
124
134
  Equation.diagonal(slope: (slope = F.tan(angle)), intercept: y - (slope * x))
125
135
  end
126
136
  end
127
137
 
128
138
  class Line
129
- # Returns the linear equation containing this line.
139
+ # Returns the linear equation through the stored endpoints without a polar conversion.
130
140
  # @return [Sevgi::Geometry::Equation::Linear]
131
- def equation = position.equation(angle)
141
+ def equation
142
+ x, y = starting.deconstruct
143
+ dx, dy = ending.x - x, ending.y - y
144
+ return Equation.horizontal(y) if dy.zero?
145
+ return Equation.vertical(x) if dx.zero?
146
+
147
+ slope = dy / dx
148
+ Equation.diagonal(slope:, intercept: y - (slope * x))
149
+ end
132
150
  end
133
151
 
134
152
  require_relative "equation/linear"
@@ -35,6 +35,14 @@ module Sevgi
35
35
 
36
36
  private_constant :Real
37
37
 
38
+ # Calculates the scalar cross product of two planar component pairs.
39
+ # @api private
40
+ module Cross
41
+ def self.[](ax, ay, bx, by) = (ax * by) - (ay * bx)
42
+ end
43
+
44
+ private_constant :Cross
45
+
38
46
  # Coerces array-like geometry inputs into typed tuple objects.
39
47
  # @api private
40
48
  module Tuple
@@ -35,10 +35,7 @@ module Sevgi
35
35
 
36
36
  case alignment
37
37
  when :center
38
- Point[
39
- that.position.x + ((that.width - this.width) / 2.0) - this.position.x,
40
- that.position.y + ((that.height - this.height) / 2.0) - this.position.y
41
- ]
38
+ Point[that.center.x - this.center.x, that.center.y - this.center.y]
42
39
  when :left
43
40
  Point[that.position.x - this.position.x, 0]
44
41
  when :right
@@ -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
@@ -11,11 +11,11 @@ module Sevgi
11
11
  # Default maximum number of sweep iterations.
12
12
  LIMIT = 1_000
13
13
 
14
- # Sweeps parallel lines across a lined element in both directions.
14
+ # Sweeps parallel lines across a geometry element in both directions.
15
15
  #
16
16
  # Generated lines are boundary-to-boundary interior spans. A single
17
- # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
18
- # @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
19
19
  # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
20
20
  # @param angle [Numeric] clockwise sweep line angle in degrees
21
21
  # @param step [Numeric] signed distance between sweep lines
@@ -38,11 +38,11 @@ module Sevgi
38
38
  end
39
39
  end
40
40
 
41
- # 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.
42
42
  #
43
43
  # Generated lines are boundary-to-boundary interior spans. A single
44
- # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
45
- # @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
46
46
  # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
47
47
  # @param angle [Numeric] clockwise sweep line angle in degrees
48
48
  # @param step [Numeric] signed distance between sweep lines
@@ -66,8 +66,8 @@ module Sevgi
66
66
  # Sweeps parallel lines in one signed direction from an equation.
67
67
  #
68
68
  # Generated lines are boundary-to-boundary interior spans. A single
69
- # sweep position can produce multiple lines for closed concave elements; open paths produce no interior lines.
70
- # @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
71
71
  # @param equation [Sevgi::Geometry::Equation] initial sweep equation
72
72
  # @param step [Numeric] signed distance between sweep lines
73
73
  # @param limit [Integer] maximum iterations
@@ -97,7 +97,7 @@ module Sevgi
97
97
  # @param element [Object] candidate element
98
98
  # @return [Boolean]
99
99
  def applicable?(element)
100
- element.respond_to?(:intersection)
100
+ element.respond_to?(:intersection) && element.respond_to?(:inside?) && element.respond_to?(:on?)
101
101
  end
102
102
 
103
103
  private
@@ -113,25 +113,23 @@ module Sevgi
113
113
  end
114
114
 
115
115
  def interior_lines(element, equation, points)
116
- return [] unless element.class.send(:close?)
116
+ return [] unless element.closed?
117
117
 
118
118
  if points.size == 2
119
+ return [] unless element.inside?(Point.midpoint(*points))
120
+
119
121
  line = simple_line(points)
120
122
 
121
123
  return line ? [line] : []
122
124
  end
123
125
 
124
126
  sorted_points(equation, points).each_cons(2).filter_map do |starting, ending|
125
- next unless element.inside?(midpoint(starting, ending))
127
+ next unless element.inside?(Point.midpoint(starting, ending))
126
128
 
127
129
  simple_line([starting, ending])
128
130
  end
129
131
  end
130
132
 
131
- def midpoint(starting, ending)
132
- Point[(starting.x + ending.x) / 2.0, (starting.y + ending.y) / 2.0]
133
- end
134
-
135
133
  def simple_line(points)
136
134
  line = Line.(*points)
137
135
 
@@ -5,9 +5,9 @@ module Sevgi
5
5
  # Stateless operations that relate or derive geometry values.
6
6
  #
7
7
  # `alignment` returns a translation offset, while `align` applies that
8
- # offset to a copy. Center alignment works on both axes; edge alignments
8
+ # offset to a copy. Center alignment works on both axes. Edge alignments
9
9
  # change only the named axis and preserve the other coordinate. `sweep`
10
- # derives boundary-to-boundary spans from a closed lined element, and
10
+ # derives boundary-to-boundary spans from a closed geometry element, and
11
11
  # `sweep!` additionally requires at least one span.
12
12
  module Operation
13
13
  extend self
@@ -48,10 +48,10 @@ module Sevgi
48
48
  # # Sevgi::Geometry::Operation.alignment(inner, outer, :bottom).approx.deconstruct # => [0.0, 13.0]
49
49
  # def alignment(element, other, alignment = :center); end
50
50
  #
51
- # # Sweeps parallel lines across a lined element in both directions.
52
- # # `angle` is the direction of the returned lines; `step` is their signed perpendicular spacing.
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
53
  # # Open paths yield no interior spans.
54
- # # @param element [Sevgi::Geometry::Element::Lined] element to intersect
54
+ # # @param element [Sevgi::Geometry::Element] element to intersect
55
55
  # # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
56
56
  # # @param angle [Numeric] clockwise sweep line angle in degrees
57
57
  # # @param step [Numeric] signed distance between sweep lines
@@ -70,9 +70,9 @@ module Sevgi
70
70
  # # lines.map(&:length).uniq # => [10.0]
71
71
  # def sweep(element, initial:, angle:, step:, limit: Sweep::LIMIT); end
72
72
  #
73
- # # Sweeps parallel lines across a lined element and requires at least one result.
74
- # # It has the same geometry as {sweep}, but raises when the result would be empty.
75
- # # @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
76
76
  # # @param initial [Sevgi::Geometry::Point, Array<Numeric>] point on the initial sweep line
77
77
  # # @param angle [Numeric] clockwise sweep line angle in degrees
78
78
  # # @param step [Numeric] signed distance between sweep lines
@@ -112,4 +112,5 @@ module Sevgi
112
112
  end
113
113
 
114
114
  require_relative "operation/align"
115
+ require_relative "operation/box"
115
116
  require_relative "operation/sweep"
@@ -78,7 +78,7 @@ module Sevgi
78
78
  # Immutable point in SVG/screen coordinates.
79
79
  #
80
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
81
+ # methods that expect a point also accept `[x, y]`. Explicit Point values are
82
82
  # most useful when a result will be transformed, compared, or reused.
83
83
  # @example Measure and rotate a point in screen coordinates
84
84
  # point = Sevgi::Geometry::Point[3, 4]
@@ -184,7 +184,17 @@ module Sevgi
184
184
  # @raise [Sevgi::Geometry::Error] when either point cannot be coerced
185
185
  def self.length(starting, ending)
186
186
  starting, ending = Tuples[Point, starting, ending]
187
- ::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)]
188
198
  end
189
199
 
190
200
  # Returns the origin point.
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sevgi
4
+ module Geometry
5
+ # Internal helpers shared by public geometry predicates.
6
+ # @api private
7
+ module Predicate
8
+ extend self
9
+
10
+ def adjacent_overlap?(a, b, c, precision: nil)
11
+ orientation(a, b, c, precision:).zero? &&
12
+ (point_on_segment?(c, a, b, precision:) || point_on_segment?(a, b, c, precision:))
13
+ end
14
+
15
+ def adjacent_overlap_in?(vertices, precision: nil)
16
+ vertices.each_index.any? do |i|
17
+ adjacent_overlap?(vertices[i - 1], vertices[i], vertices[(i + 1) % vertices.size], precision:)
18
+ end
19
+ end
20
+
21
+ def between?(value, a, b, precision: nil)
22
+ minimum, maximum = [a, b].minmax
23
+ F.ge?(value, minimum, precision:) && F.le?(value, maximum, precision:)
24
+ end
25
+
26
+ def collinear?(points, precision: nil)
27
+ # ponytail: cubic worst-case comparisons; optimize the maximum triangle area if large sets need faster queries.
28
+ points.sort_by { [it.x, it.y] }.combination(3).all? do |a, b, c|
29
+ orientation(a, b, c, precision:).zero?
30
+ end
31
+ end
32
+
33
+ def convex_turns?(vertices, precision: nil)
34
+ turns = turn_orientations(vertices, precision:)
35
+ !turns.empty? && turns.uniq.one?
36
+ end
37
+
38
+ def edges(vertices)
39
+ vertices.each_index.map { |i| [vertices[i], vertices[(i + 1) % vertices.size]] }
40
+ end
41
+
42
+ def nonadjacent_intersection?(vertices, precision: nil)
43
+ edges = edges(vertices)
44
+ # ponytail: quadratic comparisons; use a sweep-line algorithm if large polygons need faster queries.
45
+ edges.each_index.any? do |i|
46
+ ((i + 1)...edges.size).any? do |j|
47
+ !adjacent_indices?(i, j, edges.size) && segments_intersect?(*edges[i], *edges[j], precision:)
48
+ end
49
+ end
50
+ end
51
+
52
+ def orientation(a, b, c, precision: nil)
53
+ cross = Cross[b.x - a.x, b.y - a.y, c.x - a.x, c.y - a.y]
54
+ return 0 if F.zero?(cross, precision:)
55
+
56
+ F.lt?(cross, 0.0, precision:) ? -1 : 1
57
+ end
58
+
59
+ def point_on_segment?(point, a, b, precision: nil)
60
+ orientation(a, b, point, precision:).zero? &&
61
+ between?(point.x, a.x, b.x, precision:) &&
62
+ between?(point.y, a.y, b.y, precision:)
63
+ end
64
+
65
+ def repeated_vertex?(vertices, precision: nil)
66
+ vertices.each_index.any? do |i|
67
+ ((i + 1)...vertices.size).any? { |j| Point.eq?(vertices[i], vertices[j], precision:) }
68
+ end
69
+ end
70
+
71
+ def segments_intersect?(a, b, c, d, precision: nil)
72
+ first = [a, b]
73
+ second = [c, d]
74
+ turns = segment_orientations(*first, *second, precision:)
75
+
76
+ proper_intersection?(turns) || boundary_intersection?(first, second, turns, precision:)
77
+ end
78
+
79
+ def simple?(vertices, precision: nil)
80
+ !repeated_vertex?(vertices, precision:) &&
81
+ !adjacent_overlap_in?(vertices, precision:) &&
82
+ !nonadjacent_intersection?(vertices, precision:)
83
+ end
84
+
85
+ private
86
+
87
+ def adjacent_indices?(i, j, size) = j == i + 1 || (i.zero? && j == size - 1)
88
+
89
+ def boundary_intersection?(first, second, turns, precision: nil)
90
+ a, b = first
91
+ c, d = second
92
+ candidates = [[turns[0], c, a, b], [turns[1], d, a, b], [turns[2], a, c, d], [turns[3], b, c, d]]
93
+
94
+ candidates.any? { |turn, point, from, to| turn.zero? && point_on_segment?(point, from, to, precision:) }
95
+ end
96
+
97
+ def proper_intersection?(turns)
98
+ turns.none?(&:zero?) && turns[0] != turns[1] && turns[2] != turns[3]
99
+ end
100
+
101
+ def segment_orientations(a, b, c, d, precision: nil)
102
+ [
103
+ orientation(a, b, c, precision:),
104
+ orientation(a, b, d, precision:),
105
+ orientation(c, d, a, precision:),
106
+ orientation(c, d, b, precision:)
107
+ ]
108
+ end
109
+
110
+ def turn_orientations(vertices, precision: nil)
111
+ vertices
112
+ .each_index
113
+ .map { |i|
114
+ orientation(vertices[i], vertices[(i + 1) % vertices.size], vertices[(i + 2) % vertices.size], precision:)
115
+ }
116
+ .reject(&:zero?)
117
+ end
118
+ end
119
+
120
+ private_constant :Predicate
121
+
122
+ class Point
123
+ # Reports whether three or more points lie on one infinite line.
124
+ #
125
+ # Every three-point subset must have a cross product that rounds to zero at the selected decimal precision.
126
+ # The cross-product magnitude is twice the triangle area, not a distance tolerance.
127
+ # Repeated points are allowed. Input order does not affect the result, and accepted sets have accepted subsets.
128
+ # Triangle area is invariant under rigid motion, subject to floating-point error near the rounding threshold.
129
+ # The worst-case number of comparisons is cubic in the point count.
130
+ # @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] point-like values
131
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
132
+ # @return [Boolean]
133
+ # @raise [Sevgi::ArgumentError] when fewer than three points are given or precision is not an Integer or nil
134
+ # @raise [Sevgi::Geometry::Error] when a point cannot be coerced
135
+ # @example Test a point set
136
+ # Sevgi::Geometry::Point.collinear?([0, 0], [1, 1], [2, 2]) # => true
137
+ def self.collinear?(*points, precision: nil)
138
+ ArgumentError.("At least three points required") if points.size < 3
139
+
140
+ Predicate.collinear?(Tuples[self, *points], precision:)
141
+ end
142
+ end
143
+
144
+ class Polygon
145
+ # Reports whether the polygon boundary has no self-intersection.
146
+ #
147
+ # Adjacent edges may meet only at their shared endpoint. Non-adjacent
148
+ # touches and overlaps make the polygon non-simple.
149
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
150
+ # @return [Boolean]
151
+ # @raise [Sevgi::ArgumentError] when precision is not an Integer or nil
152
+ def simple?(precision: nil) = Predicate.simple?(vertices, precision:)
153
+
154
+ # Reports whether this is a simple polygon whose non-collinear turns all have one orientation.
155
+ #
156
+ # Redundant vertices on straight edges are permitted. Self-intersecting
157
+ # and fully degenerate polygons are not convex.
158
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
159
+ # @return [Boolean]
160
+ # @raise [Sevgi::ArgumentError] when precision is not an Integer or nil
161
+ def convex?(precision: nil)
162
+ simple?(precision:) && Predicate.convex_turns?(vertices, precision:)
163
+ end
164
+
165
+ # Reports whether this is a simple non-convex polygon.
166
+ #
167
+ # Self-intersecting and degenerate polygons are neither convex nor concave.
168
+ # @param precision [Integer, nil] decimal precision, or nil for the current function default
169
+ # @return [Boolean]
170
+ # @raise [Sevgi::ArgumentError] when precision is not an Integer or nil
171
+ def concave?(precision: nil)
172
+ simple?(precision:) && !Predicate.convex_turns?(vertices, precision:)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -7,7 +7,7 @@ module Sevgi
7
7
  # A Segment has no position: `length` is a distance and `angle` is a
8
8
  # clockwise direction. Use {#ending} to apply it to a starting point or
9
9
  # {#line} when a placed, finite line is required. `Segment[length, angle]`
10
- # starts from polar components; `Segment.(starting, ending)` derives them
10
+ # starts from polar components. `Segment.(starting, ending)` derives them
11
11
  # from two points.
12
12
  # @example Derive polar components from two points
13
13
  # segment = Sevgi::Geometry::Segment.([1, 2], [4, 6])
@@ -128,11 +128,13 @@ module Sevgi
128
128
  # @raise [Sevgi::Geometry::Error] when other cannot be coerced
129
129
  def eq?(other, precision: nil) = self.class.eq?(self, other, precision:)
130
130
 
131
- # Reports strict segment equality.
131
+ # Reports exact length-and-direction equality, also used by ==. Ordering through <=> compares length only.
132
132
  # @param other [Object] object to compare
133
133
  # @return [Boolean]
134
134
  def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
135
135
 
136
+ alias_method :==, :eql?
137
+
136
138
  # Returns a hash compatible with strict equality.
137
139
  # @return [Integer]
138
140
  def hash = [self.class, *deconstruct].hash
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Geometry
5
5
  # Component version.
6
- VERSION = "0.98.2"
6
+ VERSION = "1.0.0"
7
7
  end
8
8
  end
@@ -8,6 +8,7 @@ require_relative "geometry/errors"
8
8
  require_relative "geometry/point"
9
9
  require_relative "geometry/segment"
10
10
  require_relative "geometry/element"
11
+ require_relative "geometry/predicate"
11
12
  require_relative "geometry/equation"
12
13
  require_relative "geometry/operation"
13
14
 
@@ -18,8 +19,8 @@ module Sevgi
18
19
  #
19
20
  # Coordinates follow SVG screen conventions: +x points right, +y points down,
20
21
  # and positive angles turn clockwise. Constructors accept Point and Segment
21
- # objects or their two-number Array forms. Transformations return new values;
22
- # they do not mutate their receiver.
22
+ # objects or their two-number Array forms. Transformations return new values.
23
+ # They do not mutate their receiver.
23
24
  #
24
25
  # Shape constructors have two complementary notations: `Shape[...]` accepts
25
26
  # dimensions or segments, while `Shape.(...)` accepts points. Named factories
@@ -27,7 +28,7 @@ module Sevgi
27
28
  # when a call site benefits from spelling it out.
28
29
  #
29
30
  # Trigonometric construction can retain ordinary floating-point noise. Use
30
- # `approx` for presentation values and `eq?` for precision-aware comparison;
31
+ # `approx` for presentation values and `eq?` for precision-aware comparison.
31
32
  # strict `==` intentionally compares the exact immutable value.
32
33
  #
33
34
  # @example Measure and move a line
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sevgi-geometry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.98.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.98.2
18
+ version: 1.0.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.98.2
25
+ version: 1.0.0
26
26
  description: Models the points, lines, shapes, and transforms used by the DSL.
27
27
  email: roktas@gmail.com
28
28
  executables: []
@@ -34,6 +34,11 @@ files:
34
34
  - README.md
35
35
  - lib/sevgi/geometry.rb
36
36
  - lib/sevgi/geometry/element.rb
37
+ - lib/sevgi/geometry/elements/arc.rb
38
+ - lib/sevgi/geometry/elements/circle.rb
39
+ - lib/sevgi/geometry/elements/ellipse.rb
40
+ - lib/sevgi/geometry/elements/ellipse/affine.rb
41
+ - lib/sevgi/geometry/elements/ellipse/length.rb
37
42
  - lib/sevgi/geometry/elements/line.rb
38
43
  - lib/sevgi/geometry/elements/parallelogram.rb
39
44
  - lib/sevgi/geometry/elements/polygon.rb
@@ -47,8 +52,10 @@ files:
47
52
  - lib/sevgi/geometry/internal.rb
48
53
  - lib/sevgi/geometry/operation.rb
49
54
  - lib/sevgi/geometry/operation/align.rb
55
+ - lib/sevgi/geometry/operation/box.rb
50
56
  - lib/sevgi/geometry/operation/sweep.rb
51
57
  - lib/sevgi/geometry/point.rb
58
+ - lib/sevgi/geometry/predicate.rb
52
59
  - lib/sevgi/geometry/segment.rb
53
60
  - lib/sevgi/geometry/version.rb
54
61
  homepage: https://sevgi.roktas.dev
@@ -73,7 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
73
80
  - !ruby/object:Gem::Version
74
81
  version: '0'
75
82
  requirements: []
76
- rubygems_version: 4.0.16
83
+ rubygems_version: 4.0.20
77
84
  specification_version: 4
78
85
  summary: Geometry values and operations for Sevgi drawings.
79
86
  test_files: []