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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -2
- data/README.md +26 -1
- data/lib/sevgi/geometry/element.rb +115 -61
- data/lib/sevgi/geometry/elements/arc.rb +256 -0
- data/lib/sevgi/geometry/elements/circle.rb +28 -0
- data/lib/sevgi/geometry/elements/ellipse/affine.rb +72 -0
- data/lib/sevgi/geometry/elements/ellipse/length.rb +80 -0
- data/lib/sevgi/geometry/elements/ellipse.rb +269 -0
- data/lib/sevgi/geometry/elements/line.rb +12 -12
- data/lib/sevgi/geometry/elements/parallelogram.rb +5 -7
- data/lib/sevgi/geometry/elements/polyline.rb +9 -0
- data/lib/sevgi/geometry/elements/rect.rb +5 -1
- data/lib/sevgi/geometry/elements/triangle.rb +6 -9
- data/lib/sevgi/geometry/equation/quadratic.rb +98 -16
- data/lib/sevgi/geometry/equation.rb +34 -16
- data/lib/sevgi/geometry/internal.rb +8 -0
- data/lib/sevgi/geometry/operation/align.rb +1 -4
- data/lib/sevgi/geometry/operation/box.rb +50 -0
- data/lib/sevgi/geometry/operation/sweep.rb +13 -15
- data/lib/sevgi/geometry/operation.rb +9 -8
- data/lib/sevgi/geometry/point.rb +12 -2
- data/lib/sevgi/geometry/predicate.rb +176 -0
- data/lib/sevgi/geometry/segment.rb +4 -2
- data/lib/sevgi/geometry/version.rb +1 -1
- data/lib/sevgi/geometry.rb +4 -3
- metadata +11 -4
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# `[]`, while one crossing is a one-item Array.
|
|
10
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
return Equation.
|
|
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
|
|
139
|
+
# Returns the linear equation through the stored endpoints without a polar conversion.
|
|
130
140
|
# @return [Sevgi::Geometry::Equation::Linear]
|
|
131
|
-
def equation
|
|
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
|
|
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
|
|
18
|
-
# @param element [Sevgi::Geometry::Element
|
|
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
|
|
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
|
|
45
|
-
# @param element [Sevgi::Geometry::Element
|
|
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
|
|
70
|
-
# @param element [Sevgi::Geometry::Element
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
52
|
-
# # `angle` is the direction of the returned lines
|
|
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
|
|
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
|
|
74
|
-
# # It has the same geometry as {sweep}, but raises when the result
|
|
75
|
-
# # @param element [Sevgi::Geometry::Element
|
|
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"
|
data/lib/sevgi/geometry/point.rb
CHANGED
|
@@ -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]
|
|
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.
|
|
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
|
|
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
|
|
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
|
data/lib/sevgi/geometry.rb
CHANGED
|
@@ -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
|
-
#
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: []
|