geometry 6 → 6.1
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 +7 -0
- data/README.markdown +6 -1
- data/Rakefile +0 -1
- data/geometry.gemspec +1 -1
- data/lib/geometry/line.rb +14 -3
- data/lib/geometry/point.rb +13 -7
- data/lib/geometry/point_zero.rb +5 -4
- data/lib/geometry/polygon.rb +33 -27
- data/lib/geometry/polyline.rb +129 -23
- data/lib/geometry/rectangle.rb +35 -0
- data/lib/geometry/regular_polygon.rb +35 -3
- data/lib/geometry/rotation.rb +115 -3
- data/lib/geometry/size_zero.rb +2 -1
- data/lib/geometry/transformation.rb +72 -19
- data/lib/geometry/transformation/composition.rb +39 -0
- data/test/geometry/edge.rb +14 -7
- data/test/geometry/line.rb +24 -0
- data/test/geometry/point.rb +55 -14
- data/test/geometry/polygon.rb +28 -6
- data/test/geometry/polyline.rb +176 -0
- data/test/geometry/rectangle.rb +18 -1
- data/test/geometry/regular_polygon.rb +38 -2
- data/test/geometry/rotation.rb +60 -0
- data/test/geometry/transformation.rb +76 -10
- data/test/geometry/transformation/composition.rb +49 -0
- metadata +10 -15
data/lib/geometry/rectangle.rb
CHANGED
@@ -78,6 +78,8 @@ The {Rectangle} class cluster represents your typical arrangement of 4 corners a
|
|
78
78
|
SizedRectangle.new(height: options[:height], width: options[:width])
|
79
79
|
elsif (2==args.count) and (args.all? {|a| a.is_a?(Array) || a.is_a?(Point) })
|
80
80
|
original_new(*args)
|
81
|
+
elsif options.empty?
|
82
|
+
raise ArgumentError, "#{self} arguments must be named, not: #{args}"
|
81
83
|
else
|
82
84
|
raise ArgumentError, "Bad Rectangle arguments: #{args}, #{options}"
|
83
85
|
end
|
@@ -171,6 +173,39 @@ The {Rectangle} class cluster represents your typical arrangement of 4 corners a
|
|
171
173
|
max.x - min.x
|
172
174
|
end
|
173
175
|
# @endgroup
|
176
|
+
|
177
|
+
# Create a new {Rectangle} from the receiver that's inset by the given amount
|
178
|
+
# @overload inset(x, y)
|
179
|
+
# @overload inset(top, left, bottom, right)
|
180
|
+
# @overload inset(x, y)
|
181
|
+
# @option options [Number] :x Inset from the left and right sides
|
182
|
+
# @option options [Number] :y Inset from the top and bottom
|
183
|
+
# @overload inset(top, left, bottom, right)
|
184
|
+
# @option options [Number] :bottom The inset from the bottom of the {Rectangle}
|
185
|
+
# @option options [Number] :left The inset from the left side of the {Rectangle}
|
186
|
+
# @option options [Number] :right The inset from the right side of the {Rectangle}
|
187
|
+
# @option options [Number] :top The inset from the top of the {Rectangle}
|
188
|
+
def inset(*args)
|
189
|
+
options, args = args.partition {|a| a.is_a? Hash}
|
190
|
+
options = options.reduce({}, :merge)
|
191
|
+
raise ArumentError, "Can't specify both arguments and options" if !args.empty? && !options.empty?
|
192
|
+
|
193
|
+
if 1 == args.size
|
194
|
+
distance = args.shift
|
195
|
+
Rectangle.new from:(min + distance), to:(max - distance)
|
196
|
+
elsif 2 == args.size
|
197
|
+
distance = Point[*args]
|
198
|
+
Rectangle.new from:(min + distance), to:(max - distance)
|
199
|
+
elsif 4 == args.size
|
200
|
+
top, left, bottom, right = *args
|
201
|
+
Rectangle.new from:(min + Point[left, bottom]), to:(max - Point[right, top])
|
202
|
+
elsif options[:x] && options[:y]
|
203
|
+
distance = Point[options[:x], options[:y]]
|
204
|
+
Rectangle.new from:(min + distance), to:(max - distance)
|
205
|
+
elsif options[:top] && options[:left] && options[:bottom] && options[:right]
|
206
|
+
Rectangle.new from:(min + Point[options[:left], options[:bottom]]), to:(max - Point[options[:right], options[:top]])
|
207
|
+
end
|
208
|
+
end
|
174
209
|
end
|
175
210
|
|
176
211
|
class CenteredRectangle < Rectangle
|
@@ -27,18 +27,18 @@ A {RegularPolygon} is a lot like a {Polygon}, but more regular.
|
|
27
27
|
# @overload new(sides, center, radius)
|
28
28
|
# Construct a {RegularPolygon} using a center point and radius
|
29
29
|
# @option options [Number] :sides The number of edges
|
30
|
-
# @option options [Point] :center The center point of the {RegularPolygon}
|
30
|
+
# @option options [Point] :center (PointZero) The center point of the {RegularPolygon}
|
31
31
|
# @option options [Number] :radius The radius of the {RegularPolygon}
|
32
32
|
# @overload new(sides, center, diameter)
|
33
33
|
# Construct a {RegularPolygon} using a center point and diameter
|
34
34
|
# @option options [Number] :sides The number of edges
|
35
|
-
# @option options [Point] :center The center point of the {RegularPolygon}
|
35
|
+
# @option options [Point] :center (PointZero) The center point of the {RegularPolygon}
|
36
36
|
# @option options [Number] :diameter The diameter of the {RegularPolygon}
|
37
37
|
def self.new(options={}, &block)
|
38
38
|
raise ArgumentError, "RegularPolygon requires an edge count" unless options[:sides]
|
39
39
|
|
40
40
|
center = options[:center]
|
41
|
-
center = center ? Point[center] :
|
41
|
+
center = center ? Point[center] : Point.zero
|
42
42
|
|
43
43
|
if options.has_key?(:radius)
|
44
44
|
self.allocate.tap {|polygon| polygon.send :initialize, options[:sides], center, options[:radius], &block }
|
@@ -66,11 +66,43 @@ A {RegularPolygon} is a lot like a {Polygon}, but more regular.
|
|
66
66
|
alias :== :eql?
|
67
67
|
|
68
68
|
# @!group Accessors
|
69
|
+
# @return [Rectangle] The smallest axis-aligned {Rectangle} that bounds the receiver
|
70
|
+
def bounds
|
71
|
+
return Rectangle.new(self.min, self.max)
|
72
|
+
end
|
73
|
+
|
69
74
|
# @!attribute [r] diameter
|
70
75
|
# @return [Numeric] The diameter of the {RegularPolygon}
|
71
76
|
def diameter
|
72
77
|
@radius*2
|
73
78
|
end
|
79
|
+
|
80
|
+
# !@attribute [r] edges
|
81
|
+
def edges
|
82
|
+
points = self.vertices
|
83
|
+
points.each_cons(2).map {|p1,p2| Edge.new(p1,p2) } + [Edge.new(points.last, points.first)]
|
84
|
+
end
|
85
|
+
|
86
|
+
# !@attribute [r] vertices
|
87
|
+
# @return [Array]
|
88
|
+
def vertices
|
89
|
+
(0...2*Math::PI).step(2*Math::PI/edge_count).map {|angle| center + Point[Math::cos(angle), Math::sin(angle)]*radius }
|
90
|
+
end
|
91
|
+
|
92
|
+
# @return [Point] The upper right corner of the bounding {Rectangle}
|
93
|
+
def max
|
94
|
+
@center+Point[radius, radius]
|
95
|
+
end
|
96
|
+
|
97
|
+
# @return [Point] The lower left corner of the bounding {Rectangle}
|
98
|
+
def min
|
99
|
+
@center-Point[radius, radius]
|
100
|
+
end
|
101
|
+
|
102
|
+
# @return [Array<Point>] The lower left and upper right corners of the bounding {Rectangle}
|
103
|
+
def minmax
|
104
|
+
[self.min, self.max]
|
105
|
+
end
|
74
106
|
# @!endgroup
|
75
107
|
end
|
76
108
|
|
data/lib/geometry/rotation.rb
CHANGED
@@ -1,16 +1,39 @@
|
|
1
1
|
require 'matrix'
|
2
2
|
|
3
|
+
require_relative 'cluster_factory'
|
4
|
+
require_relative 'point'
|
5
|
+
|
3
6
|
module Geometry
|
4
7
|
=begin
|
5
8
|
A generalized representation of a rotation transformation.
|
9
|
+
|
10
|
+
== Usage
|
11
|
+
Rotation.new angle:45*Math.PI/180 # Rotate 45 degrees counterclockwise
|
12
|
+
Rotation.new x:[0,1] # Rotate 90 degrees counterclockwise
|
6
13
|
=end
|
7
14
|
class Rotation
|
8
|
-
|
9
|
-
|
15
|
+
include ClusterFactory
|
16
|
+
|
17
|
+
# @return [Integer] dimensions
|
10
18
|
attr_reader :dimensions
|
11
19
|
attr_reader :x, :y, :z
|
12
20
|
|
21
|
+
# @overload new(angle)
|
22
|
+
# Create a planar {Rotation} with an angle
|
23
|
+
def self.new(*args)
|
24
|
+
options = args.select {|a| a.is_a? Hash}.reduce({}, :merge)
|
25
|
+
|
26
|
+
if options.has_key? :angle
|
27
|
+
RotationAngle.new options[:angle]
|
28
|
+
elsif options.has_key?(:x) && [:x, :y, :z].one? {|k| options.has_key? k }
|
29
|
+
RotationAngle.new x:options[:x]
|
30
|
+
else
|
31
|
+
self.allocate.tap {|rotation| rotation.send :initialize, *args }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
13
35
|
# @overload initialize(options={})
|
36
|
+
# @option options [Radians] :angle Planar rotation angle
|
14
37
|
# @option options [Integer] :dimensions Dimensionality of the rotation
|
15
38
|
# @option options [Vector] :x X-axis
|
16
39
|
# @option options [Vector] :y Y-axis
|
@@ -53,8 +76,10 @@ A generalized representation of a rotation transformation.
|
|
53
76
|
end
|
54
77
|
|
55
78
|
# @attribute [r] matrix
|
56
|
-
# @return [Matrix]
|
79
|
+
# @return [Matrix] the transformation {Matrix} representing the {Rotation}
|
57
80
|
def matrix
|
81
|
+
return nil unless [@x, @y, @z].compact.size >= 2
|
82
|
+
|
58
83
|
# Force all axes to be Vectors
|
59
84
|
x,y,z = [@x, @y, @z].map {|a| a.is_a?(Array) ? Vector[*a] : a}
|
60
85
|
|
@@ -74,5 +99,92 @@ A generalized representation of a rotation transformation.
|
|
74
99
|
|
75
100
|
Matrix[*rows]
|
76
101
|
end
|
102
|
+
|
103
|
+
|
104
|
+
# Transform and return a new {Point}
|
105
|
+
# @param [Point] point the {Point} to rotate into the parent coordinate frame
|
106
|
+
# @return [Point] the rotated {Point}
|
107
|
+
def transform(point)
|
108
|
+
m = matrix
|
109
|
+
m ? Point[m * Point[point]] : point
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
class RotationAngle < Rotation
|
114
|
+
# @return [Radians] the planar rotation angle
|
115
|
+
attr_accessor :angle
|
116
|
+
|
117
|
+
# @option options [Radians] :angle the rotation angle from the parent coordinate frame
|
118
|
+
# @option options [Point] :x the X-axis expressed in the parent coordinate frame
|
119
|
+
def initialize(*args)
|
120
|
+
options, args = args.partition {|a| a.is_a? Hash}
|
121
|
+
options = options.reduce({}, :merge)
|
122
|
+
|
123
|
+
angle = options[:angle] || args[0]
|
124
|
+
|
125
|
+
if angle
|
126
|
+
@angle = angle
|
127
|
+
elsif options.has_key? :x
|
128
|
+
@angle = Math.atan2(*options[:x].to_a.reverse)
|
129
|
+
else
|
130
|
+
@angle = 0
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def eql?(other)
|
135
|
+
case other
|
136
|
+
when RotationAngle then angle.eql? other.angle
|
137
|
+
else
|
138
|
+
false
|
139
|
+
end
|
140
|
+
end
|
141
|
+
alias :== :eql?
|
142
|
+
|
143
|
+
# @group Accessors
|
144
|
+
# !@attribute [r] matrix
|
145
|
+
# @return [Matrix] the transformation {Matrix} representing the {Rotation}
|
146
|
+
def matrix
|
147
|
+
return nil unless angle
|
148
|
+
|
149
|
+
c, s = Math.cos(angle), Math.sin(angle)
|
150
|
+
Matrix[[c, -s], [s, c]]
|
151
|
+
end
|
152
|
+
|
153
|
+
# !@attribute [r] x
|
154
|
+
# @return [Point] the X-axis expressed in the parent coordinate frame
|
155
|
+
def x
|
156
|
+
Point[Math.cos(angle), Math.sin(angle)]
|
157
|
+
end
|
158
|
+
|
159
|
+
# !@attribute [r] y
|
160
|
+
# @return [Point] the Y-axis expressed in the parent coordinate frame
|
161
|
+
def y
|
162
|
+
Point[-Math.sin(angle), Math.cos(angle)]
|
163
|
+
end
|
164
|
+
# @endgroup
|
165
|
+
|
166
|
+
# @group Composition
|
167
|
+
def -@
|
168
|
+
RotationAngle.new(-angle)
|
169
|
+
end
|
170
|
+
|
171
|
+
def +(other)
|
172
|
+
case other
|
173
|
+
when RotationAngle
|
174
|
+
RotationAngle.new(angle + other.angle)
|
175
|
+
else
|
176
|
+
raise TypeError, "Can't compose a #{self.class} with a #{other.class}"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
def -(other)
|
181
|
+
case other
|
182
|
+
when RotationAngle
|
183
|
+
RotationAngle.new(angle - other.angle)
|
184
|
+
else
|
185
|
+
raise TypeError, "Can't subtract #{other.class} from #{self.class}"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
# @endgroup
|
77
189
|
end
|
78
190
|
end
|
data/lib/geometry/size_zero.rb
CHANGED
@@ -5,7 +5,8 @@ module Geometry
|
|
5
5
|
An object repesenting a zero {Size} in N-dimensional space
|
6
6
|
|
7
7
|
A {SizeZero} object is a {Size} that will always compare equal to zero and unequal to
|
8
|
-
everything else, regardless of dimensionality.
|
8
|
+
everything else, regardless of dimensionality. You can think of it as an application of the
|
9
|
+
{http://en.wikipedia.org/wiki/Null_Object_pattern Null Object Pattern}.
|
9
10
|
=end
|
10
11
|
class SizeZero
|
11
12
|
def eql?(other)
|
@@ -1,9 +1,11 @@
|
|
1
1
|
require 'geometry/point'
|
2
2
|
require 'geometry/rotation'
|
3
3
|
|
4
|
+
require_relative 'transformation/composition'
|
5
|
+
|
4
6
|
module Geometry
|
5
7
|
=begin
|
6
|
-
{Transformation} represents a relationship between two coordinate frames
|
8
|
+
{Transformation} represents a relationship between two coordinate frames.
|
7
9
|
|
8
10
|
To create a pure translation relationship:
|
9
11
|
|
@@ -38,6 +40,7 @@ system's X-axis:
|
|
38
40
|
# @option options [Point] :origin Same as :translate
|
39
41
|
# @option options [Point] :move Same as :translate
|
40
42
|
# @option options [Point] :translate Linear displacement
|
43
|
+
# @option options [Angle] :angle Rotation angle (assumes planar geometry)
|
41
44
|
# @option options [Rotation] :rotate Rotation
|
42
45
|
# @option options [Vector] :scale Scaling
|
43
46
|
# @option options [Vector] :x X-axis
|
@@ -50,7 +53,8 @@ system's X-axis:
|
|
50
53
|
|
51
54
|
@dimensions = options[:dimensions] || nil
|
52
55
|
|
53
|
-
|
56
|
+
rotation_options = options.select {|key, value| [:angle, :x, :y, :z].include? key }
|
57
|
+
@rotation = options[:rotate] || rotate || ((rotation_options.size > 0) ? Geometry::Rotation.new(rotation_options) : nil)
|
54
58
|
@scale = options[:scale] || scale
|
55
59
|
|
56
60
|
case options.count {|k,v| [:move, :origin, :translate].include? k }
|
@@ -62,6 +66,7 @@ system's X-axis:
|
|
62
66
|
raise ArgumentError, "Too many translation parameters in #{options}"
|
63
67
|
end
|
64
68
|
|
69
|
+
raise ArgumentError, "Bad translation" if @translation.is_a? Hash
|
65
70
|
@translation = Point[*@translation]
|
66
71
|
if @translation
|
67
72
|
@translation = nil if @translation.all? {|v| v == 0}
|
@@ -77,42 +82,90 @@ system's X-axis:
|
|
77
82
|
end
|
78
83
|
end
|
79
84
|
|
85
|
+
def initialize_clone(source)
|
86
|
+
super
|
87
|
+
@rotation = @rotation.clone if @rotation
|
88
|
+
@scale = @scale.clone if @scale
|
89
|
+
@translation = @translation.clone if @translation
|
90
|
+
end
|
91
|
+
|
92
|
+
# !@attribute [r] has_rotation?
|
93
|
+
# @return [Bool] true if the transformation has any rotation components
|
94
|
+
def has_rotation?
|
95
|
+
!!@rotation
|
96
|
+
end
|
97
|
+
|
80
98
|
# Returns true if the {Transformation} is the identity transformation
|
81
99
|
def identity?
|
82
|
-
@rotation
|
100
|
+
!(@rotation || @scale || @translation)
|
83
101
|
end
|
84
102
|
|
85
103
|
def eql?(other)
|
86
|
-
|
104
|
+
return false unless other
|
105
|
+
return true if !self.dimensions && !other.dimensions && !self.rotation && !other.rotation && !self.translation && !other.translation && !self.scale && !other.scale
|
106
|
+
return false if !(self.dimensions && other.dimensions) && !(self.rotation && other.rotation) && !(self.translation && other.translation) && !(self.scale && other.scale)
|
107
|
+
|
108
|
+
((self.dimensions && other.dimensions && self.dimensions.eql?(other.dimensions)) || !(self.dimensions && other.dimensions)) &&
|
109
|
+
((self.rotation && other.rotation && self.rotation.eql?(other.rotation)) || !(self.rotation && other.rotation)) &&
|
110
|
+
((self.scale && other.scale && self.scale.eql?(other.scale)) || !(self.scale && other.rotation)) &&
|
111
|
+
((self.translation && other.translation && self.translation.eql?(other.translation)) || !(self.scale && other.rotation))
|
87
112
|
end
|
88
113
|
alias :== :eql?
|
89
114
|
|
90
115
|
# Compose the current {Transformation} with another one
|
91
116
|
def +(other)
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
117
|
+
return self.clone unless other
|
118
|
+
|
119
|
+
case other
|
120
|
+
when Array, Vector
|
121
|
+
if @translation
|
122
|
+
Transformation.new(@translation+other, @rotation, @scale)
|
123
|
+
else
|
124
|
+
Transformation.new(other, @rotation, @scale)
|
125
|
+
end
|
126
|
+
when Composition
|
127
|
+
Composition.new(self, *other.transformations)
|
128
|
+
when Transformation
|
129
|
+
if @rotation || other.rotation
|
130
|
+
Composition.new(self, other)
|
131
|
+
else
|
132
|
+
translation = @translation ? (@translation + other.translation) : other.translation
|
133
|
+
Transformation.new(translation, @rotation, @scale)
|
134
|
+
end
|
98
135
|
end
|
99
136
|
end
|
100
137
|
|
101
138
|
def -(other)
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
139
|
+
return self.clone unless other
|
140
|
+
|
141
|
+
case other
|
142
|
+
when Array, Vector
|
143
|
+
if @translation
|
144
|
+
Transformation.new(@translation-other, @rotation, @scale)
|
145
|
+
else
|
146
|
+
Transformation.new(other.map {|e| -e}, @rotation, @scale)
|
147
|
+
end
|
148
|
+
when Transformation
|
149
|
+
if @rotation
|
150
|
+
rotation = other.rotation ? (@rotation - other.rotation) : @rotation
|
151
|
+
elsif other.rotation
|
152
|
+
rotation = -other.rotation
|
153
|
+
else
|
154
|
+
rotation = nil
|
155
|
+
end
|
156
|
+
|
157
|
+
translation = @translation ? (@translation - other.translation) : -other.translation
|
158
|
+
|
159
|
+
Transformation.new(translation, rotation, @scale)
|
108
160
|
end
|
109
161
|
end
|
110
162
|
|
111
|
-
# Transform and return a new {Point}
|
112
|
-
# @param [Point] point
|
163
|
+
# Transform and return a new {Point}. Rotation is applied before translation.
|
164
|
+
# @param [Point] point the {Point} to transform into the parent coordinate frame
|
113
165
|
# @return [Point] The transformed {Point}
|
114
166
|
def transform(point)
|
115
|
-
@
|
167
|
+
point = @rotation.transform(point) if @rotation
|
168
|
+
@translation ? (@translation + point) : point
|
116
169
|
end
|
117
170
|
end
|
118
171
|
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Geometry
|
2
|
+
class Transformation
|
3
|
+
class Composition
|
4
|
+
attr_reader :transformations
|
5
|
+
|
6
|
+
def initialize(*args)
|
7
|
+
raise TypeError unless args.all? {|a| a.is_a? Transformation }
|
8
|
+
@transformations = *args
|
9
|
+
end
|
10
|
+
|
11
|
+
def +(other)
|
12
|
+
case other
|
13
|
+
when Transformation
|
14
|
+
Composition.new(*transformations, other)
|
15
|
+
when Composition
|
16
|
+
Composition.new(*transformations, *other.transformations)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# @group Accessors
|
21
|
+
# !@attribute [r] has_rotation?
|
22
|
+
# @return [Bool] true if the transformation has any rotation components
|
23
|
+
def has_rotation?
|
24
|
+
transformations.any? {|t| t.is_a?(Rotation) || t.has_rotation? }
|
25
|
+
end
|
26
|
+
|
27
|
+
# !@attribute [r] size
|
28
|
+
# @return [Number] the number of composed {Transformation}s
|
29
|
+
def size
|
30
|
+
transformations.size
|
31
|
+
end
|
32
|
+
# @endgroup
|
33
|
+
|
34
|
+
def transform(point)
|
35
|
+
transformations.reverse.reduce(point) {|point, transformation| transformation.transform(point) }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|