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,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
@@ -2,10 +2,31 @@
2
2
 
3
3
  module Sevgi
4
4
  module Geometry
5
- # Immutable polar segment in SVG/screen coordinates.
5
+ # Immutable polar displacement in SVG/screen coordinates.
6
6
  #
7
- # `length` is a distance and `angle` is a clockwise angle in degrees.
8
- # Use `Segment[length, angle]` to create a segment from polar components.
7
+ # A Segment has no position: `length` is a distance and `angle` is a
8
+ # clockwise direction. Use {#ending} to apply it to a starting point or
9
+ # {#line} when a placed, finite line is required. `Segment[length, angle]`
10
+ # starts from polar components. `Segment.(starting, ending)` derives them
11
+ # from two points.
12
+ # @example Derive polar components from two points
13
+ # segment = Sevgi::Geometry::Segment.([1, 2], [4, 6])
14
+ # segment.length # => 5.0
15
+ # segment.ending([1, 2]).deconstruct # => [4.0, 6.0]
16
+ # @example Use a cardinal direction
17
+ # Sevgi::Geometry::Segment.upward(3).ending([5, 5]).deconstruct # => [5.0, 2.0]
18
+ # @see Sevgi::Geometry::Line
19
+ # @!parse
20
+ # class Segment
21
+ # # Creates a segment from polar components.
22
+ # # @param length [Numeric] non-negative segment length
23
+ # # @param angle [Numeric] clockwise angle in degrees
24
+ # # @return [Sevgi::Geometry::Segment]
25
+ # # @raise [Sevgi::Geometry::Error] when a component is not finite or length is negative
26
+ # # @example Create a segment with mathematical notation
27
+ # # Sevgi::Geometry::Segment[5, 30]
28
+ # def self.[](length, angle); end
29
+ # end
9
30
  Segment = Data.define(:length, :angle) do
10
31
  include Comparable
11
32
 
@@ -56,9 +77,15 @@ module Sevgi
56
77
  def self.upward(length) = self[length, -90.0]
57
78
 
58
79
  class << self
59
- # @return [Sevgi::Geometry::Segment]
80
+ # @overload horizontal(length)
81
+ # Returns a rightward segment.
82
+ # @param length [Numeric] segment length
83
+ # @return [Sevgi::Geometry::Segment]
60
84
  alias_method :horizontal, :rightward
61
- # @return [Sevgi::Geometry::Segment]
85
+ # @overload vertical(length)
86
+ # Returns a downward segment.
87
+ # @param length [Numeric] segment length
88
+ # @return [Sevgi::Geometry::Segment]
62
89
  alias_method :vertical, :downward
63
90
  end
64
91
 
@@ -66,14 +93,22 @@ module Sevgi
66
93
  # @param length [Numeric] segment length
67
94
  # @param angle [Numeric] clockwise angle in degrees
68
95
  # @return [void]
69
- # @raise [Sevgi::Geometry::Error] when a component is not a finite Numeric
70
- def initialize(length:, angle:) = super(length: Real[:length, length], angle: Real[:angle, angle])
96
+ # @raise [Sevgi::Geometry::Error] when a component is not finite or length is negative
97
+ def initialize(length:, angle:)
98
+ length = Real[:length, length]
99
+ Error.("Segment length cannot be negative") if length.negative?
100
+
101
+ super(length:, angle: Real[:angle, angle])
102
+ end
71
103
 
72
104
  # Compares segments by length.
73
- # @param other [Sevgi::Geometry::Segment, Array<Numeric>] segment to compare
74
- # @return [Integer, nil]
75
- # @raise [Sevgi::Geometry::Error] when other cannot be coerced
76
- def <=>(other) = length <=> Tuple[Segment, other].length
105
+ # @param other [Object] segment or two-item length/angle array to compare
106
+ # @return [Integer, nil] comparison result, or nil when other is not a valid segment value
107
+ def <=>(other)
108
+ length <=> Tuple[Segment, other].length
109
+ rescue Error
110
+ nil
111
+ end
77
112
 
78
113
  # Returns a segment rounded to precision.
79
114
  # @param precision [Integer, nil] decimal precision, or nil for the current function default
@@ -93,11 +128,13 @@ module Sevgi
93
128
  # @raise [Sevgi::Geometry::Error] when other cannot be coerced
94
129
  def eq?(other, precision: nil) = self.class.eq?(self, other, precision:)
95
130
 
96
- # Reports strict segment equality.
131
+ # Reports exact length-and-direction equality, also used by ==. Ordering through <=> compares length only.
97
132
  # @param other [Object] object to compare
98
133
  # @return [Boolean]
99
134
  def eql?(other) = self.class == other.class && deconstruct == other.deconstruct
100
135
 
136
+ alias_method :==, :eql?
137
+
101
138
  # Returns a hash compatible with strict equality.
102
139
  # @return [Integer]
103
140
  def hash = [self.class, *deconstruct].hash
@@ -121,7 +158,35 @@ module Sevgi
121
158
  def y = length * F.sin(angle)
122
159
  end
123
160
 
124
- # Lightweight polar value used where a plain length/angle tuple is enough.
125
- Polar = Data.define(:length, :angle)
161
+ # Immutable length/angle constraint used when a full segment is not implied.
162
+ #
163
+ # @!attribute [r] length
164
+ # @return [Float] non-negative target measure
165
+ # @!attribute [r] angle
166
+ # @return [Float] direction used to derive a segment
167
+ # @!parse
168
+ # class LengthAngle
169
+ # # Creates a length-and-angle constraint.
170
+ # # @param length [Numeric] finite non-negative target width or height
171
+ # # @param angle [Numeric] finite direction in degrees
172
+ # # @return [Sevgi::Geometry::LengthAngle]
173
+ # # @raise [Sevgi::Geometry::Error] when a component is not finite or length is negative
174
+ # # @example Create a constraint with mathematical notation
175
+ # # Sevgi::Geometry::LengthAngle[3, 90]
176
+ # def self.[](length, angle); end
177
+ # end
178
+ LengthAngle = Data.define(:length, :angle) do
179
+ # Creates a target-measure constraint.
180
+ # @param length [Numeric] finite non-negative target width or height
181
+ # @param angle [Numeric] finite direction in degrees
182
+ # @return [void]
183
+ # @raise [Sevgi::Geometry::Error] when a component is not finite or length is negative
184
+ def initialize(length:, angle:)
185
+ length = Real[:length, length]
186
+ Error.("LengthAngle length cannot be negative") if length.negative?
187
+
188
+ super(length:, angle: Real[:angle, angle])
189
+ end
190
+ end
126
191
  end
127
192
  end
@@ -3,6 +3,6 @@
3
3
  module Sevgi
4
4
  module Geometry
5
5
  # Component version.
6
- VERSION = "0.95.0"
6
+ VERSION = "1.0.0"
7
7
  end
8
8
  end
@@ -8,16 +8,40 @@ 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
 
14
15
  require_relative "geometry/version"
15
16
 
16
17
  module Sevgi
17
- # Screen-space geometry primitives used by Sevgi layout and drawing helpers.
18
+ # Immutable screen-space geometry values used by Sevgi layout and drawing helpers.
18
19
  #
19
20
  # Coordinates follow SVG screen conventions: +x points right, +y points down,
20
- # and positive angles turn clockwise.
21
+ # and positive angles turn clockwise. Constructors accept Point and Segment
22
+ # objects or their two-number Array forms. Transformations return new values.
23
+ # They do not mutate their receiver.
24
+ #
25
+ # Shape constructors have two complementary notations: `Shape[...]` accepts
26
+ # dimensions or segments, while `Shape.(...)` accepts points. Named factories
27
+ # such as `Rect.from_size` and `Rect.from_corners` expose the same distinction
28
+ # when a call site benefits from spelling it out.
29
+ #
30
+ # Trigonometric construction can retain ordinary floating-point noise. Use
31
+ # `approx` for presentation values and `eq?` for precision-aware comparison.
32
+ # strict `==` intentionally compares the exact immutable value.
33
+ #
34
+ # @example Measure and move a line
35
+ # line = Sevgi::Geometry::Line.([0, 0], [3, 4])
36
+ # line.length #=> 5.0
37
+ # line.translate(2, 1).starting #=> Point[2.0, 1.0]
38
+ # @example Follow SVG screen directions
39
+ # origin = Sevgi::Geometry::Point.origin
40
+ # Sevgi::Geometry::Point.angle(origin, [0, 10]) #=> 90.0
41
+ # origin.translate(0, 10).below?(origin) #=> true
42
+ # @see Sevgi::Geometry::Element::Lined
43
+ # @see Sevgi::Geometry::Operation
44
+ # @see https://sevgi.roktas.dev/geometry/ Geometry guide
21
45
  module Geometry
22
46
  end
23
47
  end
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.95.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Recai Oktaş
@@ -15,15 +15,15 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.95.0
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.95.0
26
- description: Enhances the Sevgi toolkit with geometry objects and methods.
25
+ version: 1.0.0
26
+ description: Models the points, lines, shapes, and transforms used by the DSL.
27
27
  email: roktas@gmail.com
28
28
  executables: []
29
29
  extensions: []
@@ -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.11
83
+ rubygems_version: 4.0.20
77
84
  specification_version: 4
78
- summary: Tiny library for geometric computations.
85
+ summary: Geometry values and operations for Sevgi drawings.
79
86
  test_files: []