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
|
@@ -2,35 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
module Sevgi
|
|
4
4
|
module Geometry
|
|
5
|
-
#
|
|
5
|
+
# Abstract base class for positioned geometry values.
|
|
6
|
+
#
|
|
7
|
+
# Construct concrete shapes through their class factories. Element
|
|
8
|
+
# operations return new values, and {#box} supplies the axis-aligned bounds
|
|
9
|
+
# used by alignment and tiling helpers.
|
|
6
10
|
class Element
|
|
11
|
+
private_class_method :new
|
|
12
|
+
|
|
7
13
|
# @overload lined(size = Undefined, open: false)
|
|
8
14
|
# Builds a lined element subclass.
|
|
15
|
+
# Instances expose total path `length`. Closed classes also expose `perimeter`.
|
|
9
16
|
# @param size [Integer, Sevgi::Undefined] segment count for fixed-size elements, or Undefined for variable size
|
|
10
17
|
# @param open [Boolean] true for an open path, false for a closed path
|
|
11
18
|
# @return [Class] subclass of {Sevgi::Geometry::Element::Lined}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
17
|
-
# @param args [Array<Object>] arced factory arguments
|
|
18
|
-
# @return [Class]
|
|
19
|
-
# @raise [NoMethodError] until arced elements are implemented
|
|
20
|
-
def self.arced(...) = Arced.build(...)
|
|
21
|
-
|
|
22
|
-
private_class_method :arced
|
|
19
|
+
# @raise [Sevgi::Geometry::Error] when size is not Undefined or a positive Integer, or open is not Boolean
|
|
20
|
+
# @example Define a custom two-segment open shape
|
|
21
|
+
# Path = Sevgi::Geometry::Element.lined(2, open: true)
|
|
22
|
+
# Path.([0, 0], [1, 0], [1, 1])
|
|
23
|
+
def self.lined(...) = Lined.send(:build, ...)
|
|
23
24
|
|
|
24
25
|
# Core API
|
|
25
26
|
|
|
26
27
|
# Returns a copy moved to a point and optional offset.
|
|
28
|
+
# @example Position a copy without mutating the source
|
|
29
|
+
# source = Sevgi::Geometry::Rect[8, 4, position: [1, 2]]
|
|
30
|
+
# moved = source.at([10, 20], dx: 2)
|
|
31
|
+
# source.position.deconstruct # => [1.0, 2.0]
|
|
32
|
+
# moved.position.deconstruct # => [12.0, 20.0]
|
|
27
33
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>, nil] target position, or nil to keep current position
|
|
28
34
|
# @param dx [Numeric] additional x offset
|
|
29
35
|
# @param dy [Numeric] additional y offset
|
|
30
36
|
# @return [Sevgi::Geometry::Element] translated element
|
|
31
|
-
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
37
|
+
# @raise [Sevgi::Geometry::Error] when point or an offset cannot be coerced to finite geometry values
|
|
32
38
|
def at(point = nil, dx: 0, dy: 0)
|
|
33
39
|
point = point ? Tuple[Point, point] : position
|
|
40
|
+
dx = Real[:dx, dx]
|
|
41
|
+
dy = Real[:dy, dy]
|
|
34
42
|
|
|
35
43
|
translate(
|
|
36
44
|
(point.x - position.x) + dx,
|
|
@@ -44,7 +52,12 @@ module Sevgi
|
|
|
44
52
|
# @raise [Sevgi::PanicError] when a subclass does not implement box
|
|
45
53
|
def box = PanicError.("#{self.class}#box must be implemented")
|
|
46
54
|
|
|
47
|
-
#
|
|
55
|
+
# Reports whether the element boundary forms a closed path.
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def closed? = self.class.send(:close?)
|
|
58
|
+
|
|
59
|
+
# Returns carrier equations for candidate boundary intersections.
|
|
60
|
+
# A finite element can represent only part of each carrier.
|
|
48
61
|
# @abstract Subclasses implement element-specific equations.
|
|
49
62
|
# @return [Array<Sevgi::Geometry::Equation>]
|
|
50
63
|
# @raise [Sevgi::PanicError] when a subclass does not implement equations
|
|
@@ -55,6 +68,29 @@ module Sevgi
|
|
|
55
68
|
# @return [Boolean]
|
|
56
69
|
def ignorable?(precision: nil) = F.zero?(box.width, precision:) && F.zero?(box.height, precision:)
|
|
57
70
|
|
|
71
|
+
# Intersects the element boundary with an equation.
|
|
72
|
+
#
|
|
73
|
+
# Precision controls boundary membership, returned-coordinate rounding, and duplicate collapse.
|
|
74
|
+
# A nil precision uses the current thread's function precision for all three stages.
|
|
75
|
+
# @example Intersect a rectangle with a vertical line
|
|
76
|
+
# rect = Sevgi::Geometry::Rect[8, 4]
|
|
77
|
+
# axis = Sevgi::Geometry::Equation.vertical(3)
|
|
78
|
+
# rect.intersection(axis).map(&:deconstruct) # => [[3.0, 0.0], [3.0, 4.0]]
|
|
79
|
+
# @param equation [Sevgi::Geometry::Equation] equation to intersect with
|
|
80
|
+
# @param precision [Integer, nil] coordinate precision, or nil for the current function default
|
|
81
|
+
# @return [Array<Sevgi::Geometry::Point>] unique boundary intersection points
|
|
82
|
+
# @raise [Sevgi::Geometry::Error] when equation is not an equation
|
|
83
|
+
# @raise [Sevgi::PanicError] when the equation combination is not implemented
|
|
84
|
+
def intersection(equation, precision: nil)
|
|
85
|
+
Error.("Must be an equation: #{equation}") unless equation.is_a?(Equation)
|
|
86
|
+
|
|
87
|
+
points = equations.flat_map do |candidate|
|
|
88
|
+
equation.intersect(candidate).select { |point| boundary_point?(point, precision) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
points.map { |point| point.approx(precision) }.uniq
|
|
92
|
+
end
|
|
93
|
+
|
|
58
94
|
# Returns the element position.
|
|
59
95
|
# @abstract Subclasses implement element-specific positioning.
|
|
60
96
|
# @return [Sevgi::Geometry::Point]
|
|
@@ -69,18 +105,54 @@ module Sevgi
|
|
|
69
105
|
# @raise [Sevgi::PanicError] when a subclass does not implement translate
|
|
70
106
|
def translate(_x, _y) = PanicError.("#{self.class}#translate must be implemented")
|
|
71
107
|
|
|
72
|
-
|
|
108
|
+
def boundary_point?(point, precision)
|
|
109
|
+
return on?(point) if precision.nil?
|
|
110
|
+
|
|
111
|
+
F.with_precision(precision) { on?(point) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private :boundary_point?
|
|
115
|
+
|
|
73
116
|
# Element whose boundary is represented by straight segments.
|
|
117
|
+
#
|
|
118
|
+
# The same path is available as immutable {#points}, {#segments}, and
|
|
119
|
+
# {#lines} collections. {#vertices} omits the repeated closing point from
|
|
120
|
+
# closed shapes. Open paths have the same values in `vertices` and `points`.
|
|
121
|
+
# Only closed shapes have a filled interior, so `inside?` on an open path
|
|
122
|
+
# is equivalent to testing its boundary.
|
|
123
|
+
# @example Inspect path and geometric views
|
|
124
|
+
# rect = Sevgi::Geometry::Rect[8, 4]
|
|
125
|
+
# rect.vertices.size # => 4
|
|
126
|
+
# rect.points.size # => 5
|
|
127
|
+
# rect.segments.size # => 4
|
|
128
|
+
# rect.lines.size # => 4
|
|
129
|
+
# @see Sevgi::Geometry::Operation.sweep
|
|
74
130
|
class Lined < self
|
|
75
131
|
# Open lined element base class.
|
|
132
|
+
# @api private
|
|
76
133
|
Open = Class.new(self) do
|
|
134
|
+
# Returns the first point in the directed path.
|
|
135
|
+
# @return [Sevgi::Geometry::Point]
|
|
136
|
+
def starting = points.first
|
|
137
|
+
|
|
138
|
+
# Returns the last point in the directed path.
|
|
139
|
+
# @return [Sevgi::Geometry::Point]
|
|
140
|
+
def ending = points.last
|
|
141
|
+
|
|
142
|
+
# Returns the same trace with opposite traversal.
|
|
143
|
+
# @return [Sevgi::Geometry::Element::Lined]
|
|
144
|
+
def reverse = self.class.send(:new_by_points!, *points.reverse)
|
|
145
|
+
|
|
77
146
|
# Draws the element as an SVG polyline.
|
|
78
147
|
# @param node [Object] graphics node receiving the drawing command
|
|
79
148
|
# @return [Object] graphics node command result
|
|
80
149
|
def draw!(node, **) = node.polyline(points: points.map { it.deconstruct.join(",") }, **)
|
|
150
|
+
|
|
151
|
+
private :draw!
|
|
81
152
|
end
|
|
82
153
|
|
|
83
154
|
# Closed lined element base class.
|
|
155
|
+
# @api private
|
|
84
156
|
Close = Class.new(self) do
|
|
85
157
|
# Creates a closed element from points, appending the first point.
|
|
86
158
|
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
|
|
@@ -88,126 +160,163 @@ module Sevgi
|
|
|
88
160
|
# @raise [Sevgi::Geometry::Error] when any point cannot be coerced
|
|
89
161
|
def self.new_by_points(*points) = super(*points, points.first)
|
|
90
162
|
|
|
163
|
+
# Returns the closed path perimeter.
|
|
164
|
+
# @return [Float]
|
|
165
|
+
def perimeter = length
|
|
166
|
+
|
|
91
167
|
# Draws the element as an SVG polygon.
|
|
92
168
|
# @param node [Object] graphics node receiving the drawing command
|
|
93
169
|
# @return [Object] graphics node command result
|
|
94
170
|
def draw!(node, **) = node.polygon(points: points.map { it.deconstruct.join(",") }, **)
|
|
171
|
+
|
|
172
|
+
private :draw!
|
|
173
|
+
|
|
174
|
+
private_class_method :new_by_points
|
|
95
175
|
end
|
|
96
176
|
|
|
97
177
|
# Class methods
|
|
98
178
|
|
|
99
179
|
# Point shortcut names generated for fixed-size lined elements.
|
|
180
|
+
# @api private
|
|
100
181
|
SHORTCUTS = ("A".."Z").to_a.freeze
|
|
182
|
+
private_constant :Close, :Open, :SHORTCUTS
|
|
183
|
+
|
|
184
|
+
class << self
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
# Builds a concrete lined element class.
|
|
188
|
+
# @param size [Integer, Sevgi::Undefined] segment count for fixed-size elements, or Undefined for variable size
|
|
189
|
+
# @param open [Boolean] true for an open path, false for a closed path
|
|
190
|
+
# @return [Class] lined element subclass
|
|
191
|
+
# @raise [Sevgi::Geometry::Error] when size is not Undefined or a positive Integer, or open is not Boolean
|
|
192
|
+
# @api private
|
|
193
|
+
def build(size = Undefined, open: false)
|
|
194
|
+
validate_factory(size, open)
|
|
195
|
+
|
|
196
|
+
klass = Class.new(open ? Open : Close)
|
|
197
|
+
klass.define_singleton_method(:close?) { !open }
|
|
198
|
+
klass.define_singleton_method(:poly?) { size.equal?(Undefined) }
|
|
199
|
+
klass.define_singleton_method(:size) { size }
|
|
200
|
+
define_shortcuts(klass, size, open:) unless size.equal?(Undefined)
|
|
201
|
+
klass.public_class_method(:[], :call, :from_points, :from_segments)
|
|
202
|
+
klass.private_class_method(:close?, :poly?, :size)
|
|
203
|
+
klass
|
|
204
|
+
end
|
|
101
205
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def self.build(size = Undefined, open: false)
|
|
107
|
-
Class.new(open ? Open : Close) do
|
|
108
|
-
define_singleton_method(:close?) { !open }
|
|
109
|
-
|
|
110
|
-
define_singleton_method(:open?) { open }
|
|
111
|
-
|
|
112
|
-
define_singleton_method(:poly?) { size == Undefined }
|
|
113
|
-
|
|
114
|
-
define_singleton_method(:size) { size }
|
|
206
|
+
def validate_factory(size, open)
|
|
207
|
+
unless size.equal?(Undefined) || (size.is_a?(::Integer) && size.positive?)
|
|
208
|
+
Error.("Lined segment count must be a positive Integer or Undefined")
|
|
209
|
+
end
|
|
115
210
|
|
|
116
|
-
|
|
211
|
+
Error.("Lined open flag must be Boolean") unless open.equal?(true) || open.equal?(false)
|
|
117
212
|
end
|
|
118
|
-
end
|
|
119
213
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] exact boundary points
|
|
161
|
-
# @return [Sevgi::Geometry::Element::Lined]
|
|
162
|
-
# @raise [Sevgi::Geometry::Error] when points cannot be coerced or do not satisfy the class path contract
|
|
163
|
-
def self.new_by_points!(*points)
|
|
164
|
-
new do
|
|
165
|
-
@points = Tuples[Point, *points]
|
|
214
|
+
# @overload [](*segments, position: Origin)
|
|
215
|
+
# Builds an element from segments.
|
|
216
|
+
# @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
|
|
217
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
218
|
+
# @return [Sevgi::Geometry::Element::Lined]
|
|
219
|
+
# @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
|
|
220
|
+
# @api private
|
|
221
|
+
def [](...) = new_by_segments(...)
|
|
222
|
+
|
|
223
|
+
# @overload call(*points)
|
|
224
|
+
# Builds an element from points.
|
|
225
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
|
|
226
|
+
# @return [Sevgi::Geometry::Element::Lined]
|
|
227
|
+
# @raise [Sevgi::Geometry::Error] when points cannot be coerced
|
|
228
|
+
# @api private
|
|
229
|
+
def call(...) = new_by_points(...)
|
|
230
|
+
|
|
231
|
+
# @overload from_points(*points)
|
|
232
|
+
# Builds an element from points.
|
|
233
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
|
|
234
|
+
# @return [Sevgi::Geometry::Element::Lined]
|
|
235
|
+
# @raise [Sevgi::Geometry::Error] when points cannot be coerced
|
|
236
|
+
# @api private
|
|
237
|
+
def from_points(...) = call(...)
|
|
238
|
+
|
|
239
|
+
# @overload from_segments(*segments, position: Origin)
|
|
240
|
+
# Builds an element from segments.
|
|
241
|
+
# @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
|
|
242
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
243
|
+
# @return [Sevgi::Geometry::Element::Lined]
|
|
244
|
+
# @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
|
|
245
|
+
# @api private
|
|
246
|
+
def from_segments(*segments, position: Origin) = self[*segments, position:]
|
|
247
|
+
|
|
248
|
+
def affine(*points) = new_by_points!(*points)
|
|
249
|
+
|
|
250
|
+
def approximate(*points)
|
|
251
|
+
new_by_points!(*points)
|
|
252
|
+
rescue Error
|
|
253
|
+
(close? ? Polygon : Polyline).send(:new_by_points!, *points)
|
|
166
254
|
end
|
|
167
|
-
end
|
|
168
255
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
256
|
+
# @overload new_by_points(*points)
|
|
257
|
+
# Builds an element from points, applying closed-path behavior where appropriate.
|
|
258
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] boundary points
|
|
259
|
+
# @return [Sevgi::Geometry::Element::Lined]
|
|
260
|
+
# @raise [Sevgi::Geometry::Error] when points cannot be coerced
|
|
261
|
+
# @api private
|
|
262
|
+
def new_by_points(...) = new_by_points!(...)
|
|
263
|
+
|
|
264
|
+
# Builds an element from an exact point path.
|
|
265
|
+
#
|
|
266
|
+
# Closed classes require the closing point to be supplied by the caller.
|
|
267
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] exact boundary points
|
|
268
|
+
# @return [Sevgi::Geometry::Element::Lined]
|
|
269
|
+
# @raise [Sevgi::Geometry::Error] when points cannot be coerced or do not satisfy the class path contract
|
|
270
|
+
# @api private
|
|
271
|
+
def new_by_points!(*points)
|
|
272
|
+
new do
|
|
273
|
+
@points = Tuples[Point, *points]
|
|
274
|
+
end
|
|
178
275
|
end
|
|
179
|
-
end
|
|
180
276
|
|
|
181
|
-
|
|
277
|
+
# Builds an element from segments and a start position.
|
|
278
|
+
# @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] boundary segments
|
|
279
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
280
|
+
# @return [Sevgi::Geometry::Element::Lined]
|
|
281
|
+
# @raise [Sevgi::Geometry::Error] when segments or position cannot be coerced
|
|
282
|
+
# @api private
|
|
283
|
+
def new_by_segments(*segments, position: Origin)
|
|
284
|
+
new do
|
|
285
|
+
@position = Tuple[Point, position]
|
|
286
|
+
@segments = Tuples[Segment, *segments]
|
|
287
|
+
end
|
|
288
|
+
end
|
|
182
289
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
290
|
+
def define_line_shortcuts(klass, point_names, closing:)
|
|
291
|
+
line_names = point_names.each_cons(2).map(&:join)
|
|
292
|
+
line_names << "#{point_names.last}#{point_names.first}" if closing
|
|
186
293
|
|
|
187
|
-
|
|
188
|
-
|
|
294
|
+
line_names.each_with_index do |name, i|
|
|
295
|
+
klass.define_method(name) { lines[i] or Error.("No such line: #{name}") }
|
|
296
|
+
end
|
|
189
297
|
end
|
|
190
|
-
end
|
|
191
298
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
299
|
+
def define_point_shortcuts(klass, point_names)
|
|
300
|
+
point_names.each_with_index do |name, i|
|
|
301
|
+
klass.define_method(name) { points[i] or Error.("No such point: #{name}") }
|
|
302
|
+
end
|
|
195
303
|
end
|
|
196
|
-
end
|
|
197
304
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
305
|
+
def define_shortcuts(klass, size, open:)
|
|
306
|
+
point_names = SHORTCUTS.first([open ? size + 1 : size, SHORTCUTS.size].min)
|
|
307
|
+
define_point_shortcuts(klass, point_names)
|
|
308
|
+
define_line_shortcuts(klass, point_names, closing: !open && point_names.size == size)
|
|
309
|
+
end
|
|
202
310
|
end
|
|
203
311
|
|
|
204
|
-
private_class_method :
|
|
312
|
+
private_class_method :new
|
|
205
313
|
|
|
206
314
|
# Creates a lined element from a geometry-definition block.
|
|
207
315
|
# @yield evaluates point or segment definitions in the new element
|
|
208
316
|
# @yieldreturn [Object] ignored block result
|
|
209
317
|
# @return [void]
|
|
210
318
|
# @raise [Sevgi::Geometry::Error] when the block is absent or defines inconsistent geometry
|
|
319
|
+
# @api private
|
|
211
320
|
def initialize(&block)
|
|
212
321
|
super()
|
|
213
322
|
|
|
@@ -220,18 +329,20 @@ module Sevgi
|
|
|
220
329
|
freeze_geometry!
|
|
221
330
|
|
|
222
331
|
sanitize
|
|
332
|
+
validate_geometry!
|
|
223
333
|
end
|
|
224
334
|
|
|
225
335
|
# Core methods
|
|
226
336
|
|
|
227
|
-
# Returns an element
|
|
337
|
+
# Returns an element rebuilt from rounded boundary points.
|
|
338
|
+
#
|
|
339
|
+
# Segments are derived from the rounded points so both representations describe the same path. When rounding
|
|
340
|
+
# breaks a concrete shape invariant, the result widens to a less specific Rect, Polygon, or Polyline rather than
|
|
341
|
+
# retaining a misleading concrete class.
|
|
342
|
+
# @param precision [Integer, nil] decimal precision, or nil for the current function default
|
|
228
343
|
# @return [Sevgi::Geometry::Element::Lined]
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
self.class.send(:new) do
|
|
232
|
-
@points, @segments = points, segments
|
|
233
|
-
end
|
|
234
|
-
end
|
|
344
|
+
# @raise [Sevgi::ArgumentError] when precision is invalid
|
|
345
|
+
def approx(precision = nil) = self.class.send(:approximate, *rounded_points(precision))
|
|
235
346
|
|
|
236
347
|
# @overload draw(node, **attributes)
|
|
237
348
|
# Draws an approximate element into a graphics node.
|
|
@@ -239,14 +350,24 @@ module Sevgi
|
|
|
239
350
|
# @param attributes [Hash] drawing attributes
|
|
240
351
|
# @return [Object] graphics node command result
|
|
241
352
|
def draw(...)
|
|
242
|
-
approx.draw
|
|
353
|
+
approx.send(:draw!, ...)
|
|
243
354
|
end
|
|
244
355
|
|
|
245
|
-
# Returns immutable element points.
|
|
356
|
+
# Returns immutable element points in path order.
|
|
357
|
+
# Closed elements repeat the first vertex at the end.
|
|
246
358
|
# @param approximate [Boolean] true to round points with the current function precision
|
|
247
359
|
# @return [Array<Sevgi::Geometry::Point>] frozen point collection
|
|
248
360
|
def points(approximate = false)
|
|
249
|
-
approximate ?
|
|
361
|
+
approximate ? rounded_points(nil) : @points
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Returns immutable geometric vertices in path order.
|
|
365
|
+
# Closed elements omit the repeated closing point. Open elements return {#points}.
|
|
366
|
+
# @return [Array<Sevgi::Geometry::Point>] frozen vertex collection
|
|
367
|
+
def vertices
|
|
368
|
+
return points unless closed?
|
|
369
|
+
|
|
370
|
+
@vertices ||= points[...-1].freeze
|
|
250
371
|
end
|
|
251
372
|
|
|
252
373
|
# Returns the first point.
|
|
@@ -259,53 +380,64 @@ module Sevgi
|
|
|
259
380
|
# @param approximate [Boolean] true to round segments with the current function precision
|
|
260
381
|
# @return [Array<Sevgi::Geometry::Segment>] frozen segment collection
|
|
261
382
|
def segments(approximate = false)
|
|
262
|
-
approximate ?
|
|
383
|
+
approximate ? rounded_segments(nil) : @segments
|
|
263
384
|
end
|
|
264
385
|
|
|
265
386
|
# Affinity methods
|
|
266
387
|
|
|
388
|
+
# Affine operations preserve a shape class while its semantic invariant still holds. Axis-aligned Rect and
|
|
389
|
+
# Square instances widen to Rect or Parallelogram when rotation, skew, or unequal scaling changes that category.
|
|
390
|
+
|
|
267
391
|
# @!parse
|
|
268
392
|
# # Returns an element reflected across the selected axes.
|
|
269
393
|
# # @param x [Boolean] reflect across the x-axis
|
|
270
394
|
# # @param y [Boolean] reflect across the y-axis
|
|
271
395
|
# # @return [Sevgi::Geometry::Element::Lined]
|
|
396
|
+
# # @raise [Sevgi::Geometry::Error] when a flag is not Boolean
|
|
272
397
|
# def reflect(x: true, y: true); end
|
|
273
398
|
#
|
|
274
399
|
# # Returns an element rotated around the origin.
|
|
275
400
|
# # @param a [Numeric] clockwise angle in degrees
|
|
276
401
|
# # @return [Sevgi::Geometry::Element::Lined]
|
|
402
|
+
# # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
|
|
277
403
|
# def rotate(a); end
|
|
278
404
|
#
|
|
279
405
|
# # Returns an element scaled from the origin.
|
|
280
406
|
# # @param sx [Numeric] x scale factor
|
|
281
407
|
# # @param sy [Numeric, Sevgi::Undefined] y scale factor, defaulting to sx
|
|
282
408
|
# # @return [Sevgi::Geometry::Element::Lined]
|
|
409
|
+
# # @raise [Sevgi::Geometry::Error] when a scale is not a finite real number
|
|
283
410
|
# def scale(sx, sy = Undefined); end
|
|
284
411
|
#
|
|
285
412
|
# # Returns an element skewed from the origin.
|
|
286
413
|
# # @param ax [Numeric] x-axis skew angle in degrees
|
|
287
414
|
# # @param ay [Numeric, Sevgi::Undefined] y-axis skew angle in degrees, defaulting to ax
|
|
288
415
|
# # @return [Sevgi::Geometry::Element::Lined]
|
|
416
|
+
# # @raise [Sevgi::Geometry::Error] when an angle is not a finite real number
|
|
289
417
|
# def skew(ax, ay = Undefined); end
|
|
290
418
|
#
|
|
291
419
|
# # Returns an element skewed along x.
|
|
292
420
|
# # @param a [Numeric] skew angle in degrees
|
|
293
421
|
# # @return [Sevgi::Geometry::Element::Lined]
|
|
422
|
+
# # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
|
|
294
423
|
# def skew_x(a); end
|
|
295
424
|
#
|
|
296
425
|
# # Returns an element skewed along y.
|
|
297
426
|
# # @param a [Numeric] skew angle in degrees
|
|
298
427
|
# # @return [Sevgi::Geometry::Element::Lined]
|
|
428
|
+
# # @raise [Sevgi::Geometry::Error] when angle is not a finite real number
|
|
299
429
|
# def skew_y(a); end
|
|
300
430
|
#
|
|
301
431
|
# # Returns an element translated by offset.
|
|
302
432
|
# # @param dx [Numeric] x offset
|
|
303
433
|
# # @param dy [Numeric, Sevgi::Undefined] y offset, defaulting to dx
|
|
304
434
|
# # @return [Sevgi::Geometry::Element::Lined]
|
|
435
|
+
# # @raise [Sevgi::Geometry::Error] when an offset is not a finite real number
|
|
305
436
|
# def translate(dx, dy = Undefined); end
|
|
306
|
-
|
|
437
|
+
Affinity.public_instance_methods(false).each do |transform|
|
|
307
438
|
define_method(transform) do |*args, **kwargs, &block|
|
|
308
|
-
|
|
439
|
+
transformed = points.map { it.public_send(transform, *args, **kwargs, &block) }
|
|
440
|
+
self.class.send(:affine, *transformed)
|
|
309
441
|
end
|
|
310
442
|
end
|
|
311
443
|
|
|
@@ -338,32 +470,13 @@ module Sevgi
|
|
|
338
470
|
# @return [Array<Sevgi::Geometry::Equation::Linear>] frozen equation collection
|
|
339
471
|
def equations = @equations ||= lines.map(&:equation).freeze
|
|
340
472
|
|
|
341
|
-
# Intersects the element boundary with an equation.
|
|
342
|
-
#
|
|
343
|
-
# Boundary membership is tested on unrounded candidate points. `precision:`
|
|
344
|
-
# only rounds returned coordinates and controls duplicate collapse after
|
|
345
|
-
# membership has been accepted. When `precision` is nil, returned points use
|
|
346
|
-
# the current function precision.
|
|
347
|
-
# @param equation [Sevgi::Geometry::Equation] equation to intersect with
|
|
348
|
-
# @param precision [Integer, nil] decimal precision for returned points, or nil for the current function default
|
|
349
|
-
# @return [Array<Sevgi::Geometry::Point>] unique boundary intersection points
|
|
350
|
-
# @raise [Sevgi::Geometry::Error] when equation is not an equation
|
|
351
|
-
# @raise [Sevgi::PanicError] when the equation combination is not implemented
|
|
352
|
-
def intersection(equation, precision: nil)
|
|
353
|
-
points = equations.flat_map do |candidate|
|
|
354
|
-
equation.intersect(candidate).select { |point| boundary_point?(point, precision) }
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
points.map { |point| point.approx(precision) }.uniq
|
|
358
|
-
end
|
|
359
|
-
|
|
360
473
|
# Properties
|
|
361
474
|
|
|
362
475
|
# Returns a line by index.
|
|
363
476
|
# @param i [Integer] line index
|
|
364
477
|
# @return [Sevgi::Geometry::Line]
|
|
365
478
|
# @raise [Sevgi::Geometry::Error] when no line exists for index
|
|
366
|
-
def [](i) = lines[i].tap { |line| Error.("No line
|
|
479
|
+
def [](i) = lines[i].tap { |line| Error.("No line exists for index: #{i}") unless line }
|
|
367
480
|
|
|
368
481
|
# Returns the bounding rectangle.
|
|
369
482
|
# @return [Sevgi::Geometry::Rect]
|
|
@@ -373,26 +486,31 @@ module Sevgi
|
|
|
373
486
|
# @param i [Integer] point index
|
|
374
487
|
# @return [Sevgi::Geometry::Point]
|
|
375
488
|
# @raise [Sevgi::Geometry::Error] when no point exists for index
|
|
376
|
-
def call(i) = points[i].tap { Error.("No point
|
|
489
|
+
def call(i) = points[i].tap { Error.("No point exists for index: #{i}") unless it }
|
|
377
490
|
|
|
378
491
|
# Returns the first segment.
|
|
379
492
|
# @return [Sevgi::Geometry::Segment]
|
|
380
493
|
def head = @head ||= segments.first
|
|
381
494
|
|
|
382
|
-
# Returns immutable boundary lines
|
|
495
|
+
# Returns immutable boundary lines with the stored endpoints and segments.
|
|
383
496
|
# @return [Array<Sevgi::Geometry::Line>] frozen line collection
|
|
384
497
|
def lines
|
|
385
|
-
@lines ||=
|
|
386
|
-
.
|
|
387
|
-
.
|
|
388
|
-
|
|
389
|
-
|
|
498
|
+
@lines ||= points
|
|
499
|
+
.each_cons(2)
|
|
500
|
+
.zip(segments)
|
|
501
|
+
.map do |points, segment|
|
|
502
|
+
# Rebuilding from either view alone loses stored endpoints or input angles.
|
|
503
|
+
Line.send(:new) do
|
|
504
|
+
@points = points
|
|
505
|
+
@segments = [segment]
|
|
506
|
+
end
|
|
507
|
+
end
|
|
390
508
|
.freeze
|
|
391
509
|
end
|
|
392
510
|
|
|
393
|
-
# Returns the
|
|
511
|
+
# Returns the total path length.
|
|
394
512
|
# @return [Float]
|
|
395
|
-
def
|
|
513
|
+
def length = @length ||= segments.sum(&:length)
|
|
396
514
|
|
|
397
515
|
# Returns the last segment.
|
|
398
516
|
# @return [Sevgi::Geometry::Segment]
|
|
@@ -402,15 +520,21 @@ module Sevgi
|
|
|
402
520
|
|
|
403
521
|
# Reports whether a point is inside or on the boundary.
|
|
404
522
|
#
|
|
405
|
-
# Open paths have no filled interior
|
|
523
|
+
# Open paths have no filled interior. For them this predicate is true
|
|
406
524
|
# only for points on the actual path boundary.
|
|
525
|
+
# @example Compare closed and open path containment
|
|
526
|
+
# rect = Sevgi::Geometry::Rect[8, 4]
|
|
527
|
+
# line = Sevgi::Geometry::Line.([0, 0], [8, 0])
|
|
528
|
+
# rect.inside?([4, 2]) # => true
|
|
529
|
+
# line.inside?([4, 2]) # => false
|
|
530
|
+
# line.inside?([4, 0]) # => true
|
|
407
531
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
408
532
|
# @return [Boolean]
|
|
409
533
|
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
410
534
|
def inside?(point)
|
|
411
535
|
point = Tuple[Point, point]
|
|
412
536
|
|
|
413
|
-
return on?(point)
|
|
537
|
+
return on?(point) unless closed?
|
|
414
538
|
|
|
415
539
|
on?(point) || pnpoly(points, point)
|
|
416
540
|
end
|
|
@@ -433,18 +557,11 @@ module Sevgi
|
|
|
433
557
|
|
|
434
558
|
private
|
|
435
559
|
|
|
436
|
-
def boundary_point?(point, precision)
|
|
437
|
-
return on?(point) if precision.nil?
|
|
438
|
-
|
|
439
|
-
F.with_precision(precision) { on?(point) }
|
|
440
|
-
end
|
|
441
|
-
|
|
442
560
|
def calculate_points_from_segments
|
|
443
561
|
Error.("No segments found") unless segments
|
|
444
562
|
|
|
445
563
|
[point = position, *segments.map { point = it.ending(point) }].tap do |points|
|
|
446
|
-
|
|
447
|
-
points[-1] = points.first if points.first.eq?(points.last)
|
|
564
|
+
points[-1] = points.first if closed? && points.first.eq?(points.last)
|
|
448
565
|
end
|
|
449
566
|
end
|
|
450
567
|
|
|
@@ -484,23 +601,62 @@ module Sevgi
|
|
|
484
601
|
end
|
|
485
602
|
# rubocop:enable Metrics/MethodLength
|
|
486
603
|
|
|
604
|
+
def rounded_points(precision)
|
|
605
|
+
rounded = @points.map { it.approx(precision) }
|
|
606
|
+
rounded[-1] = rounded.first if closed?
|
|
607
|
+
rounded.freeze
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def rounded_segments(precision)
|
|
611
|
+
rounded_points(precision).each_cons(2).map { Segment.(*it) }.freeze
|
|
612
|
+
end
|
|
613
|
+
|
|
487
614
|
def sanitize
|
|
488
|
-
np = self.class.poly? ? points.size : self.class.size + 1
|
|
615
|
+
np = self.class.send(:poly?) ? points.size : self.class.send(:size) + 1
|
|
489
616
|
ns = np - 1
|
|
490
617
|
|
|
491
618
|
Error.("Wrong number of points; expected #{np} where found #{points.size}") unless points.size == np
|
|
492
619
|
Error.("Wrong number of segments; expected #{ns} where found #{segments.size}") unless segments.size == ns
|
|
493
|
-
|
|
620
|
+
return unless closed? && !points.first.eq?(points.last)
|
|
621
|
+
|
|
622
|
+
Error.("Element points must form a closed path")
|
|
494
623
|
end
|
|
624
|
+
|
|
625
|
+
def validate_geometry! = nil
|
|
495
626
|
end
|
|
496
627
|
|
|
497
|
-
#
|
|
498
|
-
#
|
|
628
|
+
# Abstract family of circular and elliptical boundary elements.
|
|
629
|
+
# Arc is open, while Ellipse and Circle have closed boundaries. Concrete values share exact and precision-aware comparisons.
|
|
499
630
|
class Arced < self
|
|
631
|
+
# Compares canonical fields with coordinate and numeric precision.
|
|
632
|
+
# @param other [Object] comparison target
|
|
633
|
+
# @param precision [Integer, nil] decimal precision, or nil for the current function default
|
|
634
|
+
# @return [Boolean]
|
|
635
|
+
def eq?(other, precision: nil)
|
|
636
|
+
other.instance_of?(self.class) &&
|
|
637
|
+
state.zip(other.send(:state)).all? do |left, right|
|
|
638
|
+
left.is_a?(::Numeric) ? F.eq?(left, right, precision:) : left.eq?(right, precision:)
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Reports strict equality by concrete class and canonical fields.
|
|
643
|
+
# @param other [Object] comparison target
|
|
644
|
+
# @return [Boolean]
|
|
645
|
+
def eql?(other) = other.instance_of?(self.class) && state == other.send(:state)
|
|
646
|
+
|
|
647
|
+
# Returns a hash independent of numeric precision.
|
|
648
|
+
# @return [Integer]
|
|
649
|
+
def hash = [self.class, *state].hash
|
|
650
|
+
|
|
651
|
+
# Reports whether a point is outside the element.
|
|
652
|
+
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
653
|
+
# @return [Boolean]
|
|
654
|
+
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
655
|
+
def outside?(point) = !inside?(point)
|
|
656
|
+
|
|
657
|
+
alias == eql?
|
|
500
658
|
end
|
|
501
659
|
|
|
502
|
-
private_constant :Arced
|
|
503
|
-
# rubocop:enable Metrics/ClassLength
|
|
504
660
|
end
|
|
505
661
|
|
|
506
662
|
require_relative "elements/line"
|
|
@@ -509,5 +665,8 @@ module Sevgi
|
|
|
509
665
|
require_relative "elements/polyline"
|
|
510
666
|
require_relative "elements/rect"
|
|
511
667
|
require_relative "elements/triangle"
|
|
668
|
+
require_relative "elements/ellipse"
|
|
669
|
+
require_relative "elements/circle"
|
|
670
|
+
require_relative "elements/arc"
|
|
512
671
|
end
|
|
513
672
|
end
|