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
|
@@ -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
|
|
@@ -10,7 +10,7 @@ module Sevgi
|
|
|
10
10
|
# Finite, directed line between two endpoints.
|
|
11
11
|
#
|
|
12
12
|
# Direction affects {#left?}, {#right?}, and the sign of {#shift}. Use
|
|
13
|
-
# {#equation} when the corresponding infinite line is required
|
|
13
|
+
# {#equation} when the corresponding infinite line is required. {#over?}
|
|
14
14
|
# deliberately tests only the finite extent between the endpoints.
|
|
15
15
|
# @example Query sides of a directed line in screen coordinates
|
|
16
16
|
# line = Sevgi::Geometry::Line.([0, 0], [10, 0])
|
|
@@ -27,6 +27,15 @@ module Sevgi
|
|
|
27
27
|
# @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
|
|
28
28
|
# @return [Sevgi::Geometry::Line]
|
|
29
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]
|
|
30
39
|
# @!attribute [r] A
|
|
31
40
|
# @return [Sevgi::Geometry::Point] starting point
|
|
32
41
|
# @!attribute [r] B
|
|
@@ -69,10 +78,6 @@ module Sevgi
|
|
|
69
78
|
# @return [Float]
|
|
70
79
|
def angle = head.angle
|
|
71
80
|
|
|
72
|
-
# Returns the ending point.
|
|
73
|
-
# @return [Sevgi::Geometry::Point]
|
|
74
|
-
def ending = points.last
|
|
75
|
-
|
|
76
81
|
# Reports whether a point is left of the directed line from {#starting} to {#ending} in screen coordinates.
|
|
77
82
|
# Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
|
|
78
83
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
@@ -87,10 +92,6 @@ module Sevgi
|
|
|
87
92
|
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
88
93
|
def right?(point) = F.gt?(side(point), 0.0)
|
|
89
94
|
|
|
90
|
-
# Returns the starting point.
|
|
91
|
-
# @return [Sevgi::Geometry::Point]
|
|
92
|
-
def starting = points.first
|
|
93
|
-
|
|
94
95
|
# Draws the line into a graphics node.
|
|
95
96
|
# @param node [Object] graphics node receiving the drawing command
|
|
96
97
|
# @return [Object] graphics node command result
|
|
@@ -109,7 +110,7 @@ module Sevgi
|
|
|
109
110
|
end
|
|
110
111
|
|
|
111
112
|
# Returns a parallel line shifted by a signed perpendicular offset.
|
|
112
|
-
# Positive distance moves to the directed line's left in screen coordinates
|
|
113
|
+
# Positive distance moves to the directed line's left in screen coordinates. Reversing the endpoints reverses the
|
|
113
114
|
# shift direction.
|
|
114
115
|
# @param distance [Numeric] signed perpendicular offset
|
|
115
116
|
# @return [Sevgi::Geometry::Line]
|
|
@@ -121,12 +122,11 @@ module Sevgi
|
|
|
121
122
|
|
|
122
123
|
private
|
|
123
124
|
|
|
124
|
-
def cross(ax, ay, bx, by) = (ax * by) - (ay * bx)
|
|
125
125
|
def delta(from, to) = [to.x - from.x, to.y - from.y]
|
|
126
126
|
|
|
127
127
|
def side(point)
|
|
128
128
|
point = Tuple[Point, point]
|
|
129
|
-
|
|
129
|
+
Cross[*delta(starting, ending), *delta(starting, point)]
|
|
130
130
|
end
|
|
131
131
|
|
|
132
132
|
def within_range?(point)
|
|
@@ -8,7 +8,7 @@ module Sevgi
|
|
|
8
8
|
private_constant :ParallelogramBase
|
|
9
9
|
|
|
10
10
|
# Closed four-sided element whose opposite sides are equal and parallel. Every construction path rejects
|
|
11
|
-
# degenerate or unrelated side pairs
|
|
11
|
+
# degenerate or unrelated side pairs. Affine operations preserve the class while that invariant holds.
|
|
12
12
|
# @!method self.call(*points)
|
|
13
13
|
# Builds a parallelogram from four boundary points.
|
|
14
14
|
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] four boundary points
|
|
@@ -56,7 +56,7 @@ module Sevgi
|
|
|
56
56
|
# shape.box.width # => 6.0
|
|
57
57
|
# shape.box.height # => 3.0
|
|
58
58
|
class Parallelogram < ParallelogramBase
|
|
59
|
-
# Builds a parallelogram from adjacent base and side segments. Both segments originate at `position
|
|
59
|
+
# Builds a parallelogram from adjacent base and side segments. Both segments originate at `position`. `base`
|
|
60
60
|
# defines AB and `side` defines AD, regardless of their angles.
|
|
61
61
|
# @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
|
|
62
62
|
# @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
|
|
@@ -69,7 +69,7 @@ module Sevgi
|
|
|
69
69
|
new_by_segments(base, side.reverse, base.reverse, side, position:)
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
# Builds a parallelogram from a base and bounding-height constraint. The constraint length is the target height
|
|
72
|
+
# Builds a parallelogram from a base and bounding-height constraint. The constraint length is the target height.
|
|
73
73
|
# its signed angle is retained as the direction of the derived side while the component magnitude determines that
|
|
74
74
|
# side's non-negative length.
|
|
75
75
|
# @param base [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to B
|
|
@@ -96,7 +96,7 @@ module Sevgi
|
|
|
96
96
|
self[base, Segment[height / sine.abs, angle], position:]
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
# Builds a parallelogram from a side and bounding-width constraint. The constraint length is the target width
|
|
99
|
+
# Builds a parallelogram from a side and bounding-width constraint. The constraint length is the target width. Its
|
|
100
100
|
# signed angle is retained as the direction of the derived base while the component magnitude determines that
|
|
101
101
|
# base's non-negative length.
|
|
102
102
|
# @param side [Sevgi::Geometry::Segment, Array<Numeric>] segment from A to D
|
|
@@ -127,13 +127,11 @@ module Sevgi
|
|
|
127
127
|
|
|
128
128
|
def validate_geometry!
|
|
129
129
|
a, b, c, d = segments
|
|
130
|
-
valid = opposite?(a, c) && opposite?(b, d) && !F.zero?(
|
|
130
|
+
valid = opposite?(a, c) && opposite?(b, d) && !F.zero?(Cross[a.x, a.y, b.x, b.y])
|
|
131
131
|
|
|
132
132
|
Error.("Parallelogram sides must be non-degenerate opposite pairs") unless valid
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
-
def cross(a, b) = (a.x * b.y) - (a.y * b.x)
|
|
136
|
-
|
|
137
135
|
def opposite?(a, b) = F.zero?(a.x + b.x) && F.zero?(a.y + b.y)
|
|
138
136
|
end
|
|
139
137
|
end
|
|
@@ -30,6 +30,15 @@ module Sevgi
|
|
|
30
30
|
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] ordered points
|
|
31
31
|
# @return [Sevgi::Geometry::Polyline]
|
|
32
32
|
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
|
|
33
|
+
# @!method starting
|
|
34
|
+
# Returns the first point in the directed path.
|
|
35
|
+
# @return [Sevgi::Geometry::Point]
|
|
36
|
+
# @!method ending
|
|
37
|
+
# Returns the last point in the directed path.
|
|
38
|
+
# @return [Sevgi::Geometry::Point]
|
|
39
|
+
# @!method reverse
|
|
40
|
+
# Returns the same trace with opposite traversal.
|
|
41
|
+
# @return [Sevgi::Geometry::Polyline]
|
|
33
42
|
# @example Pair mathematical notation with English conveniences
|
|
34
43
|
# Sevgi::Geometry::Polyline[[2, 0], [1, 90]] == Sevgi::Geometry::Polyline.from_segments([2, 0], [1, 90])
|
|
35
44
|
# Sevgi::Geometry::Polyline.([0, 0], [2, 0]) == Sevgi::Geometry::Polyline.from_points([0, 0], [2, 0])
|
|
@@ -161,6 +161,10 @@ module Sevgi
|
|
|
161
161
|
|
|
162
162
|
private :draw!
|
|
163
163
|
|
|
164
|
+
# Returns the rectangle center.
|
|
165
|
+
# @return [Sevgi::Geometry::Point]
|
|
166
|
+
def center = Point.midpoint(top_left, bottom_right)
|
|
167
|
+
|
|
164
168
|
# Returns rectangle height.
|
|
165
169
|
# @return [Float]
|
|
166
170
|
def height = @height ||= segments[1].length
|
|
@@ -220,7 +224,7 @@ module Sevgi
|
|
|
220
224
|
end
|
|
221
225
|
end
|
|
222
226
|
|
|
223
|
-
# Rectangle with equal width and height. Use {#width} or {#height} for its side length
|
|
227
|
+
# Rectangle with equal width and height. Use {#width} or {#height} for its side length. Inherited
|
|
224
228
|
# {Element::Lined#length} returns the complete path length.
|
|
225
229
|
# @example Construct the same square from opposite corners
|
|
226
230
|
# Sevgi::Geometry::Square.([0, 0], [5, 5]) == Sevgi::Geometry::Square.from_corners([0, 0], [5, 5])
|
|
@@ -8,7 +8,7 @@ module Sevgi
|
|
|
8
8
|
private_constant :TriangleBase
|
|
9
9
|
|
|
10
10
|
# Closed three-sided element built from non-collinear segments or points. Every construction path rejects
|
|
11
|
-
# degenerate triangles
|
|
11
|
+
# degenerate triangles. Affine operations retain Triangle when the transformed points remain non-degenerate.
|
|
12
12
|
# @!method self.call(*points)
|
|
13
13
|
# Builds a triangle from three boundary points.
|
|
14
14
|
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] three boundary points
|
|
@@ -53,7 +53,7 @@ module Sevgi
|
|
|
53
53
|
# Builds a triangle from two adjacent segments.
|
|
54
54
|
#
|
|
55
55
|
# The closing segment is the direct vector from the end of `segment_b`
|
|
56
|
-
# back to `position`. Segment order controls orientation
|
|
56
|
+
# back to `position`. Segment order controls orientation. Reversing the
|
|
57
57
|
# inputs returns the corresponding opposite orientation. Zero-length or
|
|
58
58
|
# collinear inputs are rejected using the current numeric precision.
|
|
59
59
|
# @param segment_a [Sevgi::Geometry::Segment, Array<Numeric>] first segment
|
|
@@ -75,14 +75,11 @@ module Sevgi
|
|
|
75
75
|
Segment.(b.ending(a.ending(Origin)), Origin)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
def cross(a, b) = (a.x * b.y) - (a.y * b.x)
|
|
79
|
-
|
|
80
78
|
def validate!(a, b)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
79
|
+
middle = a.ending(Origin)
|
|
80
|
+
return unless F.zero?(a.length) || F.zero?(b.length) || Point.collinear?(Origin, middle, b.ending(middle))
|
|
81
|
+
|
|
82
|
+
Error.("Triangle segments must form a non-degenerate triangle")
|
|
86
83
|
end
|
|
87
84
|
end
|
|
88
85
|
|
|
@@ -3,26 +3,108 @@
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Geometry
|
|
5
5
|
class Equation
|
|
6
|
-
#
|
|
7
|
-
#
|
|
6
|
+
# Implicit second-degree carrier with an optional local coordinate origin.
|
|
7
|
+
# The origin avoids expanding large ellipse centers into coefficients that lose the radius through cancellation.
|
|
8
|
+
# @example Intersect a unit circle carrier with a horizontal line
|
|
9
|
+
# equation = Sevgi::Geometry::Equation.quadratic(1, 0, 1, 0, 0, -1)
|
|
10
|
+
# equation.y(0) # => [-1.0, 1.0]
|
|
8
11
|
class Quadratic < Equation
|
|
9
|
-
|
|
12
|
+
public_class_method :new
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
# @return [Array<Float>] immutable coefficients in a, b, c, d, e, f order
|
|
15
|
+
attr_reader :coefficients
|
|
16
|
+
# @return [Sevgi::Geometry::Point] local coordinate origin
|
|
17
|
+
attr_reader :origin
|
|
18
|
+
|
|
19
|
+
# Creates a quadratic equation in local coordinates.
|
|
20
|
+
# @param coefficients [Array<Numeric>] six finite coefficients, with a nonzero second-degree term
|
|
21
|
+
# @param origin [Sevgi::Geometry::Point, Array<Numeric>] local coordinate origin
|
|
22
|
+
# @return [void]
|
|
23
|
+
# @raise [Sevgi::Geometry::Error] when coefficients or origin are invalid
|
|
24
|
+
def initialize(*coefficients, origin: Origin)
|
|
25
|
+
super()
|
|
26
|
+
Error.("Quadratic equation requires six coefficients") unless coefficients.size == 6
|
|
27
|
+
@coefficients = coefficients.each_with_index.map { |value, i| Real[("a".."f").to_a[i], value] }.freeze
|
|
28
|
+
Error.("Quadratic equation requires a second-degree term") if @coefficients.first(3).all?(&:zero?)
|
|
29
|
+
@origin = Tuple[Point, origin]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reports exact equality of coefficients and the local origin.
|
|
33
|
+
# @param other [Object] comparison target
|
|
34
|
+
# @return [Boolean]
|
|
35
|
+
def eql?(other) = other.instance_of?(self.class) && coefficients == other.coefficients && origin == other.origin
|
|
36
|
+
|
|
37
|
+
# Returns a hash compatible with strict equality.
|
|
38
|
+
# @return [Integer]
|
|
39
|
+
def hash = [self.class, coefficients, origin].hash
|
|
40
|
+
|
|
41
|
+
# rubocop:disable Metrics/AbcSize
|
|
42
|
+
|
|
43
|
+
# Returns the finite y roots at a world x coordinate, in ascending order.
|
|
44
|
+
# @param x [Numeric] finite world x coordinate
|
|
45
|
+
# @return [Array<Float>] zero, one, or two y coordinates
|
|
46
|
+
# @raise [Sevgi::Geometry::Error] when x or a result is not finite, or y is indeterminate
|
|
47
|
+
def y(x)
|
|
48
|
+
x = Real[:x, x] - origin.x
|
|
49
|
+
a, b, c, d, e, f = coefficients
|
|
50
|
+
roots = roots(c, sum(b * x, e), sum(a * x * x, d * x, f))
|
|
51
|
+
Error.("y is indeterminate for this quadratic equation") unless roots
|
|
52
|
+
roots.map { Real[:y, it + origin.y] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# rubocop:enable Metrics/AbcSize
|
|
13
56
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
57
|
+
# @return [Boolean] exact equality of coefficients and origin
|
|
58
|
+
alias == eql?
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
|
63
|
+
def intersect_linear(line)
|
|
64
|
+
if line.is_a?(Linear::Vertical)
|
|
65
|
+
x = line.x - origin.x
|
|
66
|
+
a, b, c, d, e, f = coefficients
|
|
67
|
+
ys = roots(c, sum(b * x, e), sum(a * x * x, d * x, f))
|
|
68
|
+
return Array(ys).map { Point[line.x, it + origin.y] }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
a, b, c, d, e, f = coefficients
|
|
72
|
+
slope, intercept = line.slope, line.y(origin.x) - origin.y
|
|
73
|
+
xs = roots(
|
|
74
|
+
sum(a, b * slope, c * slope * slope),
|
|
75
|
+
sum(b * intercept, 2 * c * slope * intercept, d, e * slope),
|
|
76
|
+
sum(c * intercept * intercept, e * intercept, f)
|
|
77
|
+
)
|
|
78
|
+
Array(xs).map { |x| Point[x + origin.x, (slope * x) + intercept + origin.y] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
82
|
+
def roots(a, b, c)
|
|
83
|
+
scale = [a.abs, b.abs, c.abs].max
|
|
84
|
+
return nil if scale.zero?
|
|
85
|
+
Error.("Quadratic intersection coefficients are not finite") unless scale.finite?
|
|
86
|
+
a, b, c = [a, b, c].map { it / scale }
|
|
87
|
+
return b.zero? ? [] : [-c / b] if a.zero?
|
|
88
|
+
|
|
89
|
+
square, product = b * b, 4 * a * c
|
|
90
|
+
discriminant = square - product
|
|
91
|
+
# Only arithmetic cancellation at a tangent can merge roots; display precision does not classify them.
|
|
92
|
+
discriminant = 0.0 if discriminant.abs <= 8 * Float::EPSILON * (square.abs + product.abs)
|
|
93
|
+
return [] if discriminant.negative?
|
|
94
|
+
return [-b / (2 * a)] if discriminant.zero?
|
|
95
|
+
|
|
96
|
+
root = ::Math.sqrt(discriminant)
|
|
97
|
+
q = -0.5 * (b + (b.negative? ? -root : root))
|
|
98
|
+
[q / a, c / q].sort
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Substitution can cancel before the discriminant is formed, notably at axis-aligned tangents.
|
|
102
|
+
def sum(*terms)
|
|
103
|
+
value = terms.sum
|
|
104
|
+
Error.("Quadratic intersection coefficients are not finite") unless value.finite?
|
|
105
|
+
value.abs <= 8 * Float::EPSILON * terms.sum(&:abs) ? 0.0 : value
|
|
106
|
+
end
|
|
23
107
|
end
|
|
24
108
|
end
|
|
25
|
-
|
|
26
|
-
private_constant :Circle
|
|
27
109
|
end
|
|
28
110
|
end
|