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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +221 -2
- data/README.md +33 -8
- data/lib/sevgi/geometry/element.rb +320 -161
- 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 +68 -23
- data/lib/sevgi/geometry/elements/parallelogram.rb +108 -29
- data/lib/sevgi/geometry/elements/polygon.rb +41 -1
- data/lib/sevgi/geometry/elements/polyline.rb +45 -1
- data/lib/sevgi/geometry/elements/rect.rb +163 -29
- data/lib/sevgi/geometry/elements/triangle.rb +57 -11
- data/lib/sevgi/geometry/equation/linear.rb +131 -69
- data/lib/sevgi/geometry/equation/quadratic.rb +98 -16
- data/lib/sevgi/geometry/equation.rb +65 -24
- data/lib/sevgi/geometry/internal.rb +11 -3
- data/lib/sevgi/geometry/operation/align.rb +2 -4
- data/lib/sevgi/geometry/operation/box.rb +50 -0
- data/lib/sevgi/geometry/operation/sweep.rb +32 -18
- data/lib/sevgi/geometry/operation.rb +33 -18
- data/lib/sevgi/geometry/point.rb +126 -14
- data/lib/sevgi/geometry/predicate.rb +176 -0
- data/lib/sevgi/geometry/segment.rb +79 -14
- data/lib/sevgi/geometry/version.rb +1 -1
- data/lib/sevgi/geometry.rb +26 -2
- metadata +13 -6
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sevgi
|
|
4
|
+
module Geometry
|
|
5
|
+
# rubocop:disable Metrics/ClassLength
|
|
6
|
+
|
|
7
|
+
# Immutable ellipse with positive radii, a center, and clockwise axis rotation.
|
|
8
|
+
# Parameter angles belong to the local ellipse axes, not to polar directions from the center.
|
|
9
|
+
# @example Select a finite boundary and inspect its endpoints
|
|
10
|
+
# ellipse = Sevgi::Geometry::Ellipse[4, 2, position: [10, 20]]
|
|
11
|
+
# ellipse.point(90).deconstruct # => [10.0, 22.0]
|
|
12
|
+
# ellipse.arc(starting_angle: 0, extent: 90).ending == ellipse.point(90)
|
|
13
|
+
class Ellipse < Element::Arced
|
|
14
|
+
# Builds an ellipse from local radii and its position.
|
|
15
|
+
# @param rx [Numeric] positive local x radius
|
|
16
|
+
# @param ry [Numeric] positive local y radius
|
|
17
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] ellipse center
|
|
18
|
+
# @param rotation [Numeric] clockwise rotation in degrees
|
|
19
|
+
# @return [Sevgi::Geometry::Ellipse]
|
|
20
|
+
# @raise [Sevgi::Geometry::Error] when inputs are not finite real values or a radius is not positive
|
|
21
|
+
def self.[](rx, ry, position: Origin, rotation: 0) = new(rx, ry, position:, rotation:)
|
|
22
|
+
|
|
23
|
+
def self.close? = true
|
|
24
|
+
private_class_method :close?
|
|
25
|
+
|
|
26
|
+
# @return [Sevgi::Geometry::Point] ellipse center
|
|
27
|
+
attr_reader :position
|
|
28
|
+
# @return [Float] clockwise rotation of the local axes in degrees
|
|
29
|
+
attr_reader :rotation
|
|
30
|
+
# @return [Float] positive local x radius
|
|
31
|
+
attr_reader :rx
|
|
32
|
+
# @return [Float] positive local y radius
|
|
33
|
+
attr_reader :ry
|
|
34
|
+
|
|
35
|
+
# Creates an ellipse. Use the bracket constructor.
|
|
36
|
+
# @param rx [Numeric] positive local x radius
|
|
37
|
+
# @param ry [Numeric] positive local y radius
|
|
38
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] center
|
|
39
|
+
# @param rotation [Numeric] clockwise rotation in degrees
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @raise [Sevgi::Geometry::Error] when an input is invalid
|
|
42
|
+
def initialize(rx, ry, position:, rotation:)
|
|
43
|
+
super()
|
|
44
|
+
@rx, @ry = [[:rx, rx], [:ry, ry]].map do |field, value|
|
|
45
|
+
value = Real[field, value]
|
|
46
|
+
Error.("Ellipse #{field} must be positive") unless value.positive?
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@position = Tuple[Point, position]
|
|
51
|
+
@rotation = Real[:rotation, rotation]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rebuilds an ellipse from rounded canonical fields.
|
|
55
|
+
# @param precision [Integer, nil] decimal precision, or nil for the current function default
|
|
56
|
+
# @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
|
|
57
|
+
# @raise [Sevgi::Geometry::Error] when rounding makes a radius zero
|
|
58
|
+
# @raise [Sevgi::ArgumentError] when precision is invalid
|
|
59
|
+
def approx(precision = nil)
|
|
60
|
+
rebuild(
|
|
61
|
+
F.approx(rx, precision),
|
|
62
|
+
F.approx(ry, precision),
|
|
63
|
+
position.approx(precision),
|
|
64
|
+
F.approx(rotation, precision)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Selects a finite, directed arc on this ellipse.
|
|
69
|
+
# @param starting_angle [Numeric] local starting parameter angle in degrees
|
|
70
|
+
# @param extent [Numeric] signed angular extent strictly between -360 and 360 degrees
|
|
71
|
+
# @return [Sevgi::Geometry::Arc]
|
|
72
|
+
# @raise [Sevgi::Geometry::Error] when angles are invalid
|
|
73
|
+
def arc(extent:, starting_angle: 0)
|
|
74
|
+
parent = is_a?(Circle) ? Ellipse[rx, ry, position:] : self
|
|
75
|
+
Arc.send(:new, parent, starting_angle:, extent:)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# rubocop:disable Metrics/AbcSize
|
|
79
|
+
|
|
80
|
+
# Returns the axis-aligned bounds without display rounding.
|
|
81
|
+
# @return [Sevgi::Geometry::Rect]
|
|
82
|
+
def box
|
|
83
|
+
cosine, sine = F.cos(rotation), F.sin(rotation)
|
|
84
|
+
dx = ::Math.hypot(rx * cosine, ry * sine)
|
|
85
|
+
dy = ::Math.hypot(rx * sine, ry * cosine)
|
|
86
|
+
Rect.from_corners([position.x - dx, position.y - dy], [position.x + dx, position.y + dy])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# rubocop:enable Metrics/AbcSize
|
|
90
|
+
|
|
91
|
+
# Reports whether the radii are exactly equal.
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
def circular? = rx == ry
|
|
94
|
+
|
|
95
|
+
# rubocop:disable Metrics/AbcSize
|
|
96
|
+
|
|
97
|
+
# Draws an SVG ellipse using original geometric values.
|
|
98
|
+
# @param node [Object] graphics node receiving the element
|
|
99
|
+
# @param attributes [Hash] SVG attributes, including an optional outer transform
|
|
100
|
+
# @return [Object] graphics command result
|
|
101
|
+
def draw(node, **attributes)
|
|
102
|
+
unless rotation.zero?
|
|
103
|
+
transform = "rotate(#{rotation} #{position.x} #{position.y})"
|
|
104
|
+
attributes = attributes.merge(transform: [attributes[:transform], transform].compact.join(" "))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
node.ellipse(cx: position.x, cy: position.y, rx:, ry:, **attributes)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# rubocop:enable Metrics/AbcSize
|
|
111
|
+
|
|
112
|
+
# Reports whether the ellipse has zero angular extent. A complete ellipse is never empty.
|
|
113
|
+
# @return [Boolean]
|
|
114
|
+
def empty? = false
|
|
115
|
+
|
|
116
|
+
# Returns the complete quadratic carrier.
|
|
117
|
+
# @return [Sevgi::Geometry::Equation::Quadratic]
|
|
118
|
+
def equation = @equation ||= Equation.quadratic(*coefficients, origin: position)
|
|
119
|
+
|
|
120
|
+
# Returns the immutable collection containing the complete carrier.
|
|
121
|
+
# @return [Array<Sevgi::Geometry::Equation::Quadratic>]
|
|
122
|
+
def equations = @equations ||= [equation].freeze
|
|
123
|
+
|
|
124
|
+
# Reports whether a point is inside or on the boundary.
|
|
125
|
+
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
128
|
+
def inside?(point)
|
|
129
|
+
point = Tuple[Point, point]
|
|
130
|
+
local = local(point)
|
|
131
|
+
::Math.hypot(local.x / rx, local.y / ry) < 1.0 || on?(point)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Returns the perimeter with thread-independent numerical accuracy.
|
|
135
|
+
# @return [Float]
|
|
136
|
+
# @raise [Sevgi::Geometry::Error] when length is not finite or integration cannot meet its error target
|
|
137
|
+
def length = @length ||= arc_length(0, 360)
|
|
138
|
+
|
|
139
|
+
# Reports whether a point matches its radial boundary reference at the current coordinate precision.
|
|
140
|
+
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
141
|
+
# @return [Boolean]
|
|
142
|
+
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
143
|
+
def on?(point)
|
|
144
|
+
point = Tuple[Point, point]
|
|
145
|
+
point != position && point.eq?(self.point(parameter(point)))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Returns the closed boundary length.
|
|
149
|
+
# @return [Float]
|
|
150
|
+
def perimeter = length
|
|
151
|
+
|
|
152
|
+
# Evaluates the boundary at a local parameter angle.
|
|
153
|
+
# @param angle [Numeric] clockwise local angle in degrees
|
|
154
|
+
# @return [Sevgi::Geometry::Point]
|
|
155
|
+
# @raise [Sevgi::Geometry::Error] when angle or the resulting coordinates are not finite
|
|
156
|
+
def point(angle)
|
|
157
|
+
angle = Real[:angle, angle] % 360.0
|
|
158
|
+
Point[rx * F.cos(angle), ry * F.sin(angle)].rotate(rotation).translate(position.x, position.y)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns a reflected copy, preserving Circle where applicable.
|
|
162
|
+
# @param x [Boolean] reflect across the x-axis
|
|
163
|
+
# @param y [Boolean] reflect across the y-axis
|
|
164
|
+
# @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
|
|
165
|
+
# @raise [Sevgi::Geometry::Error] when a flag is not Boolean
|
|
166
|
+
def reflect(x: true, y: true) = affine(:reflect, x:, y:).first
|
|
167
|
+
|
|
168
|
+
# Rotates the center around the origin and the ellipse axes by the same angle.
|
|
169
|
+
# @param angle [Numeric] clockwise angle in degrees
|
|
170
|
+
# @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
|
|
171
|
+
# @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
|
|
172
|
+
def rotate(angle)
|
|
173
|
+
angle = Real[:angle, angle]
|
|
174
|
+
rebuild(rx, ry, position.rotate(angle), rotation + angle)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# rubocop:disable Metrics/AbcSize
|
|
178
|
+
|
|
179
|
+
# Scales the ellipse from the origin. Unequal factors can widen Circle to Ellipse.
|
|
180
|
+
# @param sx [Numeric] x scale factor
|
|
181
|
+
# @param sy [Numeric, Sevgi::Undefined] y factor, defaulting to sx
|
|
182
|
+
# @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
|
|
183
|
+
# @raise [Sevgi::Geometry::Error] when the transform is singular or the resulting geometry is invalid
|
|
184
|
+
def scale(sx, sy = Undefined)
|
|
185
|
+
sx, sy = Real[:sx, sx], Real[:sy, Undefined.default(sy, sx)]
|
|
186
|
+
return affine(:scale, sx, sy).first unless sx == sy
|
|
187
|
+
|
|
188
|
+
rebuild(rx * sx.abs, ry * sy.abs, position.scale(sx, sy), rotation + (sx.negative? ? 180 : 0))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# rubocop:enable Metrics/AbcSize
|
|
192
|
+
|
|
193
|
+
# Skews the ellipse from the origin.
|
|
194
|
+
# @param ax [Numeric] x-axis skew angle in degrees
|
|
195
|
+
# @param ay [Numeric, Sevgi::Undefined] y-axis angle, defaulting to ax
|
|
196
|
+
# @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
|
|
197
|
+
# @raise [Sevgi::Geometry::Error] when the transform is singular or the resulting geometry is invalid
|
|
198
|
+
def skew(ax, ay = Undefined) = affine(:skew, ax, ay).first
|
|
199
|
+
|
|
200
|
+
# Skews the ellipse along x.
|
|
201
|
+
# @param angle [Numeric] skew angle in degrees
|
|
202
|
+
# @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
|
|
203
|
+
# @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
|
|
204
|
+
def skew_x(angle) = affine(:skew_x, angle).first
|
|
205
|
+
|
|
206
|
+
# Skews the ellipse along y.
|
|
207
|
+
# @param angle [Numeric] skew angle in degrees
|
|
208
|
+
# @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
|
|
209
|
+
# @raise [Sevgi::Geometry::Error] when the angle or resulting geometry is invalid
|
|
210
|
+
def skew_y(angle) = affine(:skew_y, angle).first
|
|
211
|
+
|
|
212
|
+
# Returns a translated copy.
|
|
213
|
+
# @param dx [Numeric] x offset
|
|
214
|
+
# @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
|
|
215
|
+
# @return [Sevgi::Geometry::Ellipse, Sevgi::Geometry::Circle]
|
|
216
|
+
# @raise [Sevgi::Geometry::Error] when offsets or the resulting center are invalid
|
|
217
|
+
def translate(dx, dy = Undefined) = rebuild(rx, ry, position.translate(dx, dy), rotation)
|
|
218
|
+
|
|
219
|
+
alias center position
|
|
220
|
+
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def affine(...) = Affine.new(self).transform(...)
|
|
224
|
+
|
|
225
|
+
def arc_length(starting_angle, extent)
|
|
226
|
+
return Real[:length, rx * F.to_radians(extent.abs)] if circular?
|
|
227
|
+
|
|
228
|
+
Length.new(rx, ry).integrate(starting_angle, extent)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
232
|
+
def coefficients
|
|
233
|
+
cosine, sine = F.cos(rotation), F.sin(rotation)
|
|
234
|
+
a = ((cosine / rx) ** 2) + ((sine / ry) ** 2)
|
|
235
|
+
b = 2 * cosine * sine * (((1.0 / rx) ** 2) - ((1.0 / ry) ** 2))
|
|
236
|
+
c = ((sine / rx) ** 2) + ((cosine / ry) ** 2)
|
|
237
|
+
[a, b, c, 0, 0, -1]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# rubocop:disable-next Metrics/AbcSize
|
|
241
|
+
def extrema
|
|
242
|
+
cosine, sine = F.cos(rotation), F.sin(rotation)
|
|
243
|
+
x = F.atan2(-ry * sine, rx * cosine)
|
|
244
|
+
y = F.atan2(ry * cosine, rx * sine)
|
|
245
|
+
[x, x + 180, y, y + 180]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def local(point) = point.translate(-position.x, -position.y).rotate(-rotation)
|
|
249
|
+
|
|
250
|
+
def parameter(point)
|
|
251
|
+
point = local(point)
|
|
252
|
+
F.to_degrees(::Math.atan2(point.y / ry, point.x / rx))
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def rebuild(rx, ry, position, rotation)
|
|
256
|
+
return Circle[rx, position:] if is_a?(Circle) && rx == ry
|
|
257
|
+
|
|
258
|
+
Ellipse[rx, ry, position:, rotation:]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def state = [position, rx, ry, rotation]
|
|
262
|
+
|
|
263
|
+
require_relative "ellipse/affine"
|
|
264
|
+
require_relative "ellipse/length"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# rubocop:enable Metrics/ClassLength
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -7,7 +7,41 @@ module Sevgi
|
|
|
7
7
|
LineBase = Element.lined(1, open: true)
|
|
8
8
|
private_constant :LineBase
|
|
9
9
|
|
|
10
|
-
#
|
|
10
|
+
# Finite, directed line between two endpoints.
|
|
11
|
+
#
|
|
12
|
+
# Direction affects {#left?}, {#right?}, and the sign of {#shift}. Use
|
|
13
|
+
# {#equation} when the corresponding infinite line is required. {#over?}
|
|
14
|
+
# deliberately tests only the finite extent between the endpoints.
|
|
15
|
+
# @example Query sides of a directed line in screen coordinates
|
|
16
|
+
# line = Sevgi::Geometry::Line.([0, 0], [10, 0])
|
|
17
|
+
# line.left?([5, -2]) # => true
|
|
18
|
+
# line.right?([5, 2]) # => true
|
|
19
|
+
# line.shift(2).starting.deconstruct # => [0.0, -2.0]
|
|
20
|
+
# @example Distinguish the finite segment from its infinite equation
|
|
21
|
+
# line = Sevgi::Geometry::Line.([0, 0], [10, 0])
|
|
22
|
+
# line.over?([5, 0]) # => true
|
|
23
|
+
# line.over?([15, 0]) # => false
|
|
24
|
+
# @!method self.call(starting, ending)
|
|
25
|
+
# Builds a line from two endpoints.
|
|
26
|
+
# @param starting [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
27
|
+
# @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
|
|
28
|
+
# @return [Sevgi::Geometry::Line]
|
|
29
|
+
# @raise [Sevgi::Geometry::Error] when either point cannot be coerced
|
|
30
|
+
# @!method starting
|
|
31
|
+
# Returns the first point in the directed path.
|
|
32
|
+
# @return [Sevgi::Geometry::Point]
|
|
33
|
+
# @!method ending
|
|
34
|
+
# Returns the last point in the directed path.
|
|
35
|
+
# @return [Sevgi::Geometry::Point]
|
|
36
|
+
# @!method reverse
|
|
37
|
+
# Returns the same finite trace with opposite traversal.
|
|
38
|
+
# @return [Sevgi::Geometry::Line]
|
|
39
|
+
# @!attribute [r] A
|
|
40
|
+
# @return [Sevgi::Geometry::Point] starting point
|
|
41
|
+
# @!attribute [r] B
|
|
42
|
+
# @return [Sevgi::Geometry::Point] ending point
|
|
43
|
+
# @!attribute [r] AB
|
|
44
|
+
# @return [Sevgi::Geometry::Line] line from A to B
|
|
11
45
|
class Line < LineBase
|
|
12
46
|
# @overload [](length, angle, position: Origin)
|
|
13
47
|
# Builds a line from length and angle.
|
|
@@ -15,16 +49,18 @@ module Sevgi
|
|
|
15
49
|
# @param angle [Numeric] clockwise angle in degrees
|
|
16
50
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
17
51
|
# @return [Sevgi::Geometry::Line]
|
|
18
|
-
# @raise [Sevgi::Geometry::Error] when position cannot be coerced
|
|
19
|
-
|
|
52
|
+
# @raise [Sevgi::Geometry::Error] when length, angle, or position cannot be coerced to finite geometry values
|
|
53
|
+
# @example Mathematical notation and English convenience are equivalent
|
|
54
|
+
# Sevgi::Geometry::Line[5, 30] == Sevgi::Geometry::Line.from_length_angle(5, 30)
|
|
55
|
+
def self.[](length, angle, position: Origin) = new_by_segments(Segment[length, angle], position:)
|
|
20
56
|
|
|
21
57
|
# Builds a line from length and angle.
|
|
22
58
|
# @param length [Numeric] line length
|
|
23
59
|
# @param angle [Numeric] clockwise angle in degrees
|
|
24
60
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
25
61
|
# @return [Sevgi::Geometry::Line]
|
|
26
|
-
# @raise [Sevgi::Geometry::Error] when position cannot be coerced
|
|
27
|
-
def self.from_length_angle(length, angle, position: Origin) =
|
|
62
|
+
# @raise [Sevgi::Geometry::Error] when length, angle, or position cannot be coerced to finite geometry values
|
|
63
|
+
def self.from_length_angle(length, angle, position: Origin) = self[length, angle, position:]
|
|
28
64
|
|
|
29
65
|
# @overload from_points(starting, ending)
|
|
30
66
|
# Builds a line from two endpoints.
|
|
@@ -32,41 +68,37 @@ module Sevgi
|
|
|
32
68
|
# @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
|
|
33
69
|
# @return [Sevgi::Geometry::Line]
|
|
34
70
|
# @raise [Sevgi::Geometry::Error] when either point cannot be coerced
|
|
35
|
-
|
|
71
|
+
# @example Mathematical notation and English convenience are equivalent
|
|
72
|
+
# Sevgi::Geometry::Line.([0, 0], [3, 4]) == Sevgi::Geometry::Line.from_points([0, 0], [3, 4])
|
|
73
|
+
def self.from_points(...) = call(...)
|
|
74
|
+
|
|
75
|
+
private_class_method :from_segments
|
|
36
76
|
|
|
37
77
|
# Returns the clockwise line angle in degrees.
|
|
38
78
|
# @return [Float]
|
|
39
79
|
def angle = head.angle
|
|
40
80
|
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
def ending = points.last
|
|
44
|
-
|
|
45
|
-
# Reports whether a point is left of the line equation.
|
|
81
|
+
# Reports whether a point is left of the directed line from {#starting} to {#ending} in screen coordinates.
|
|
82
|
+
# Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
|
|
46
83
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
47
84
|
# @return [Boolean]
|
|
48
85
|
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
49
|
-
def left?(point) =
|
|
50
|
-
|
|
51
|
-
# Returns the line segment length.
|
|
52
|
-
# @return [Float]
|
|
53
|
-
def length = head.length
|
|
86
|
+
def left?(point) = F.lt?(side(point), 0.0)
|
|
54
87
|
|
|
55
|
-
# Reports whether a point is right of the line
|
|
88
|
+
# Reports whether a point is right of the directed line from {#starting} to {#ending} in screen coordinates.
|
|
89
|
+
# Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
|
|
56
90
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
57
91
|
# @return [Boolean]
|
|
58
92
|
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
59
|
-
def right?(point) =
|
|
60
|
-
|
|
61
|
-
# Returns the starting point.
|
|
62
|
-
# @return [Sevgi::Geometry::Point]
|
|
63
|
-
def starting = points.first
|
|
93
|
+
def right?(point) = F.gt?(side(point), 0.0)
|
|
64
94
|
|
|
65
95
|
# Draws the line into a graphics node.
|
|
66
96
|
# @param node [Object] graphics node receiving the drawing command
|
|
67
97
|
# @return [Object] graphics node command result
|
|
68
98
|
def draw!(node, **) = node.LineTo(x1: position.x, y1: position.y, x2: ending.x, y2: ending.y, **)
|
|
69
99
|
|
|
100
|
+
private :draw!
|
|
101
|
+
|
|
70
102
|
# Reports whether a point lies on the finite line segment.
|
|
71
103
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
72
104
|
# @return [Boolean]
|
|
@@ -78,12 +110,25 @@ module Sevgi
|
|
|
78
110
|
end
|
|
79
111
|
|
|
80
112
|
# Returns a parallel line shifted by a signed perpendicular offset.
|
|
113
|
+
# Positive distance moves to the directed line's left in screen coordinates. Reversing the endpoints reverses the
|
|
114
|
+
# shift direction.
|
|
81
115
|
# @param distance [Numeric] signed perpendicular offset
|
|
82
116
|
# @return [Sevgi::Geometry::Line]
|
|
83
|
-
|
|
117
|
+
# @raise [Sevgi::Geometry::Error] when distance is not a finite real number
|
|
118
|
+
def shift(distance)
|
|
119
|
+
distance = Real[:distance, distance]
|
|
120
|
+
translate(distance * F.sin(angle), -distance * F.cos(angle))
|
|
121
|
+
end
|
|
84
122
|
|
|
85
123
|
private
|
|
86
124
|
|
|
125
|
+
def delta(from, to) = [to.x - from.x, to.y - from.y]
|
|
126
|
+
|
|
127
|
+
def side(point)
|
|
128
|
+
point = Tuple[Point, point]
|
|
129
|
+
Cross[*delta(starting, ending), *delta(starting, point)]
|
|
130
|
+
end
|
|
131
|
+
|
|
87
132
|
def within_range?(point)
|
|
88
133
|
point = point.approx
|
|
89
134
|
points = [starting.approx, ending.approx]
|
|
@@ -7,53 +7,132 @@ module Sevgi
|
|
|
7
7
|
ParallelogramBase = Element.lined(4)
|
|
8
8
|
private_constant :ParallelogramBase
|
|
9
9
|
|
|
10
|
-
# Closed four-sided element
|
|
10
|
+
# Closed four-sided element whose opposite sides are equal and parallel. Every construction path rejects
|
|
11
|
+
# degenerate or unrelated side pairs. Affine operations preserve the class while that invariant holds.
|
|
12
|
+
# @!method self.call(*points)
|
|
13
|
+
# Builds a parallelogram from four boundary points.
|
|
14
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] four boundary points
|
|
15
|
+
# @return [Sevgi::Geometry::Parallelogram]
|
|
16
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a parallelogram
|
|
17
|
+
# @!method self.from_segments(base, side, position: Origin)
|
|
18
|
+
# Builds a parallelogram from two adjacent segments and derives their opposites.
|
|
19
|
+
# @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
|
|
20
|
+
# @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
|
|
21
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
22
|
+
# @return [Sevgi::Geometry::Parallelogram]
|
|
23
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a parallelogram
|
|
24
|
+
# @!method self.from_points(*points)
|
|
25
|
+
# Builds a parallelogram from four boundary points.
|
|
26
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] four boundary points
|
|
27
|
+
# @return [Sevgi::Geometry::Parallelogram]
|
|
28
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a parallelogram
|
|
29
|
+
# @!attribute [r] A
|
|
30
|
+
# @return [Sevgi::Geometry::Point] first vertex
|
|
31
|
+
# @!attribute [r] B
|
|
32
|
+
# @return [Sevgi::Geometry::Point] second vertex
|
|
33
|
+
# @!attribute [r] C
|
|
34
|
+
# @return [Sevgi::Geometry::Point] third vertex
|
|
35
|
+
# @!attribute [r] D
|
|
36
|
+
# @return [Sevgi::Geometry::Point] fourth vertex
|
|
37
|
+
# @!attribute [r] AB
|
|
38
|
+
# @return [Sevgi::Geometry::Line] side from A to B
|
|
39
|
+
# @!attribute [r] BC
|
|
40
|
+
# @return [Sevgi::Geometry::Line] side from B to C
|
|
41
|
+
# @!attribute [r] CD
|
|
42
|
+
# @return [Sevgi::Geometry::Line] side from C to D
|
|
43
|
+
# @!attribute [r] DA
|
|
44
|
+
# @return [Sevgi::Geometry::Line] side from D to A
|
|
45
|
+
# @!method perimeter
|
|
46
|
+
# Returns the closed path perimeter.
|
|
47
|
+
# @return [Float]
|
|
48
|
+
# @example Pair mathematical notation with English conveniences
|
|
49
|
+
# Sevgi::Geometry::Parallelogram[[2, 0], [2, -90]] ==
|
|
50
|
+
# Sevgi::Geometry::Parallelogram.from_segments([2, 0], [2, -90])
|
|
51
|
+
# points = [[0, 0], [2, 0], [2, 2], [0, 2]]
|
|
52
|
+
# Sevgi::Geometry::Parallelogram.(*points) == Sevgi::Geometry::Parallelogram.from_points(*points)
|
|
53
|
+
# @example Compare vertices with the axis-aligned bounding box
|
|
54
|
+
# shape = Sevgi::Geometry::Parallelogram.([1, 1], [5, 1], [7, 4], [3, 4])
|
|
55
|
+
# shape.C.deconstruct # => [7.0, 4.0]
|
|
56
|
+
# shape.box.width # => 6.0
|
|
57
|
+
# shape.box.height # => 3.0
|
|
11
58
|
class Parallelogram < ParallelogramBase
|
|
12
|
-
# Builds a parallelogram from adjacent
|
|
13
|
-
#
|
|
14
|
-
# @param
|
|
59
|
+
# Builds a parallelogram from adjacent base and side segments. Both segments originate at `position`. `base`
|
|
60
|
+
# defines AB and `side` defines AD, regardless of their angles.
|
|
61
|
+
# @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
|
|
62
|
+
# @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
|
|
15
63
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
16
64
|
# @return [Sevgi::Geometry::Parallelogram]
|
|
17
65
|
# @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
|
|
18
|
-
def self.[](
|
|
19
|
-
|
|
66
|
+
def self.[](base, side, position: Origin)
|
|
67
|
+
base, side = Tuples[Segment, base, side]
|
|
20
68
|
|
|
21
|
-
new_by_segments(
|
|
69
|
+
new_by_segments(base, side.reverse, base.reverse, side, position:)
|
|
22
70
|
end
|
|
23
71
|
|
|
24
|
-
# Builds a parallelogram from a
|
|
25
|
-
#
|
|
26
|
-
#
|
|
72
|
+
# Builds a parallelogram from a base and bounding-height constraint. The constraint length is the target height.
|
|
73
|
+
# its signed angle is retained as the direction of the derived side while the component magnitude determines that
|
|
74
|
+
# side's non-negative length.
|
|
75
|
+
# @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
|
|
76
|
+
# @param constraint [Sevgi::Geometry::LengthAngle, Array<Numeric>] target height and side direction
|
|
27
77
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
28
78
|
# @return [Sevgi::Geometry::Parallelogram]
|
|
29
|
-
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
79
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or the height constraint is infeasible
|
|
80
|
+
# @example Use an array constraint
|
|
81
|
+
# Sevgi::Geometry::Parallelogram.new_by_height(base: [4, 0], constraint: [3, -90])
|
|
82
|
+
# @example Use a LengthAngle constraint
|
|
83
|
+
# constraint = Sevgi::Geometry::LengthAngle.new(length: 3, angle: 90)
|
|
84
|
+
# base = Sevgi::Geometry::Segment[4, 0]
|
|
85
|
+
# Sevgi::Geometry::Parallelogram.new_by_height(base:, constraint:)
|
|
86
|
+
def self.new_by_height(base:, constraint:, position: Origin)
|
|
87
|
+
base = Tuple[Segment, base]
|
|
88
|
+
constraint = Tuple[LengthAngle, constraint]
|
|
33
89
|
|
|
34
|
-
height =
|
|
35
|
-
angle =
|
|
36
|
-
|
|
90
|
+
height = constraint.length - base.y.abs
|
|
91
|
+
angle = constraint.angle
|
|
92
|
+
sine = F.sin(angle)
|
|
93
|
+
Error.("Parallelogram height is smaller than its base span") if height.negative?
|
|
94
|
+
Error.("Parallelogram height constraint must have a vertical component") if F.zero?(sine)
|
|
37
95
|
|
|
38
|
-
self[
|
|
96
|
+
self[base, Segment[height / sine.abs, angle], position:]
|
|
39
97
|
end
|
|
40
98
|
|
|
41
|
-
# Builds a parallelogram from a
|
|
42
|
-
#
|
|
43
|
-
#
|
|
99
|
+
# Builds a parallelogram from a side and bounding-width constraint. The constraint length is the target width. Its
|
|
100
|
+
# signed angle is retained as the direction of the derived base while the component magnitude determines that
|
|
101
|
+
# base's non-negative length.
|
|
102
|
+
# @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
|
|
103
|
+
# @param constraint [Sevgi::Geometry::LengthAngle, Array<Numeric>] target width and base direction
|
|
44
104
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
45
105
|
# @return [Sevgi::Geometry::Parallelogram]
|
|
46
|
-
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
106
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or the width constraint is infeasible
|
|
107
|
+
# @example Use an array constraint
|
|
108
|
+
# Sevgi::Geometry::Parallelogram.new_by_width(side: [3, 90], constraint: [4, 180])
|
|
109
|
+
# @example Use a LengthAngle constraint
|
|
110
|
+
# constraint = Sevgi::Geometry::LengthAngle.new(length: 4, angle: 0)
|
|
111
|
+
# side = Sevgi::Geometry::Segment[3, 90]
|
|
112
|
+
# Sevgi::Geometry::Parallelogram.new_by_width(side:, constraint:)
|
|
113
|
+
def self.new_by_width(side:, constraint:, position: Origin)
|
|
114
|
+
side = Tuple[Segment, side]
|
|
115
|
+
constraint = Tuple[LengthAngle, constraint]
|
|
50
116
|
|
|
51
|
-
width =
|
|
52
|
-
angle =
|
|
53
|
-
|
|
117
|
+
width = constraint.length - side.x.abs
|
|
118
|
+
angle = constraint.angle
|
|
119
|
+
cosine = F.cos(angle)
|
|
120
|
+
Error.("Parallelogram width is smaller than its side span") if width.negative?
|
|
121
|
+
Error.("Parallelogram width constraint must have a horizontal component") if F.zero?(cosine)
|
|
54
122
|
|
|
55
|
-
self[Segment[
|
|
123
|
+
self[Segment[width / cosine.abs, angle], side, position:]
|
|
56
124
|
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def validate_geometry!
|
|
129
|
+
a, b, c, d = segments
|
|
130
|
+
valid = opposite?(a, c) && opposite?(b, d) && !F.zero?(Cross[a.x, a.y, b.x, b.y])
|
|
131
|
+
|
|
132
|
+
Error.("Parallelogram sides must be non-degenerate opposite pairs") unless valid
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def opposite?(a, b) = F.zero?(a.x + b.x) && F.zero?(a.y + b.y)
|
|
57
136
|
end
|
|
58
137
|
end
|
|
59
138
|
end
|
|
@@ -7,8 +7,48 @@ module Sevgi
|
|
|
7
7
|
PolygonBase = Element.lined
|
|
8
8
|
private_constant :PolygonBase
|
|
9
9
|
|
|
10
|
-
# Variable-size closed lined element.
|
|
10
|
+
# Variable-size closed lined element with at least three vertices.
|
|
11
|
+
# @!method self.[](*segments, position: Origin)
|
|
12
|
+
# Builds a polygon from boundary segments.
|
|
13
|
+
# @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
|
|
14
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
15
|
+
# @return [Sevgi::Geometry::Polygon]
|
|
16
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polygon
|
|
17
|
+
# @!method self.call(*points)
|
|
18
|
+
# Builds a polygon from boundary points.
|
|
19
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points without a repeated closing point
|
|
20
|
+
# @return [Sevgi::Geometry::Polygon]
|
|
21
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polygon
|
|
22
|
+
# @!method self.from_segments(*segments, position: Origin)
|
|
23
|
+
# Builds a polygon from boundary segments.
|
|
24
|
+
# @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
|
|
25
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
26
|
+
# @return [Sevgi::Geometry::Polygon]
|
|
27
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polygon
|
|
28
|
+
# @!method self.from_points(*points)
|
|
29
|
+
# Builds a polygon from boundary points.
|
|
30
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points without a repeated closing point
|
|
31
|
+
# @return [Sevgi::Geometry::Polygon]
|
|
32
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polygon
|
|
33
|
+
# @!method perimeter
|
|
34
|
+
# Returns the closed path perimeter.
|
|
35
|
+
# @return [Float]
|
|
36
|
+
# @example Pair point notation with its English convenience
|
|
37
|
+
# points = [[0, 0], [2, 0], [1, 1]]
|
|
38
|
+
# segments = Sevgi::Geometry::Polygon.(*points).segments
|
|
39
|
+
# Sevgi::Geometry::Polygon[*segments] == Sevgi::Geometry::Polygon.from_segments(*segments)
|
|
40
|
+
# Sevgi::Geometry::Polygon.(*points) == Sevgi::Geometry::Polygon.from_points(*points)
|
|
41
|
+
# @example Classify points against a closed boundary
|
|
42
|
+
# polygon = Sevgi::Geometry::Polygon.([0, 0], [6, 0], [3, 4])
|
|
43
|
+
# polygon.inside?([3, 2]) # => true
|
|
44
|
+
# polygon.on?([3, 0]) # => true
|
|
45
|
+
# polygon.outside?([7, 2]) # => true
|
|
11
46
|
class Polygon < PolygonBase
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_geometry!
|
|
50
|
+
Error.("Polygon requires at least three vertices") if points.size < 4
|
|
51
|
+
end
|
|
12
52
|
end
|
|
13
53
|
end
|
|
14
54
|
end
|