sevgi-geometry 0.95.0 → 0.98.2
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 +125 -0
- data/README.md +7 -7
- data/lib/sevgi/geometry/element.rb +222 -117
- data/lib/sevgi/geometry/elements/line.rb +60 -15
- data/lib/sevgi/geometry/elements/parallelogram.rb +110 -29
- data/lib/sevgi/geometry/elements/polygon.rb +41 -1
- data/lib/sevgi/geometry/elements/polyline.rb +36 -1
- data/lib/sevgi/geometry/elements/rect.rb +159 -29
- data/lib/sevgi/geometry/elements/triangle.rb +60 -11
- data/lib/sevgi/geometry/equation/linear.rb +131 -69
- data/lib/sevgi/geometry/equation.rb +36 -13
- data/lib/sevgi/geometry/internal.rb +3 -3
- data/lib/sevgi/geometry/operation/align.rb +1 -0
- data/lib/sevgi/geometry/operation/sweep.rb +20 -4
- data/lib/sevgi/geometry/operation.rb +28 -14
- data/lib/sevgi/geometry/point.rb +115 -13
- data/lib/sevgi/geometry/segment.rb +76 -13
- data/lib/sevgi/geometry/version.rb +1 -1
- data/lib/sevgi/geometry.rb +25 -2
- metadata +6 -6
|
@@ -7,7 +7,32 @@ 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
|
+
# @!attribute [r] A
|
|
31
|
+
# @return [Sevgi::Geometry::Point] starting point
|
|
32
|
+
# @!attribute [r] B
|
|
33
|
+
# @return [Sevgi::Geometry::Point] ending point
|
|
34
|
+
# @!attribute [r] AB
|
|
35
|
+
# @return [Sevgi::Geometry::Line] line from A to B
|
|
11
36
|
class Line < LineBase
|
|
12
37
|
# @overload [](length, angle, position: Origin)
|
|
13
38
|
# Builds a line from length and angle.
|
|
@@ -15,16 +40,18 @@ module Sevgi
|
|
|
15
40
|
# @param angle [Numeric] clockwise angle in degrees
|
|
16
41
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
17
42
|
# @return [Sevgi::Geometry::Line]
|
|
18
|
-
# @raise [Sevgi::Geometry::Error] when position cannot be coerced
|
|
19
|
-
|
|
43
|
+
# @raise [Sevgi::Geometry::Error] when length, angle, or position cannot be coerced to finite geometry values
|
|
44
|
+
# @example Mathematical notation and English convenience are equivalent
|
|
45
|
+
# Sevgi::Geometry::Line[5, 30] == Sevgi::Geometry::Line.from_length_angle(5, 30)
|
|
46
|
+
def self.[](length, angle, position: Origin) = new_by_segments(Segment[length, angle], position:)
|
|
20
47
|
|
|
21
48
|
# Builds a line from length and angle.
|
|
22
49
|
# @param length [Numeric] line length
|
|
23
50
|
# @param angle [Numeric] clockwise angle in degrees
|
|
24
51
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
25
52
|
# @return [Sevgi::Geometry::Line]
|
|
26
|
-
# @raise [Sevgi::Geometry::Error] when position cannot be coerced
|
|
27
|
-
def self.from_length_angle(length, angle, position: Origin) =
|
|
53
|
+
# @raise [Sevgi::Geometry::Error] when length, angle, or position cannot be coerced to finite geometry values
|
|
54
|
+
def self.from_length_angle(length, angle, position: Origin) = self[length, angle, position:]
|
|
28
55
|
|
|
29
56
|
# @overload from_points(starting, ending)
|
|
30
57
|
# Builds a line from two endpoints.
|
|
@@ -32,7 +59,11 @@ module Sevgi
|
|
|
32
59
|
# @param ending [Sevgi::Geometry::Point, Array<Numeric>] ending point
|
|
33
60
|
# @return [Sevgi::Geometry::Line]
|
|
34
61
|
# @raise [Sevgi::Geometry::Error] when either point cannot be coerced
|
|
35
|
-
|
|
62
|
+
# @example Mathematical notation and English convenience are equivalent
|
|
63
|
+
# Sevgi::Geometry::Line.([0, 0], [3, 4]) == Sevgi::Geometry::Line.from_points([0, 0], [3, 4])
|
|
64
|
+
def self.from_points(...) = call(...)
|
|
65
|
+
|
|
66
|
+
private_class_method :from_segments
|
|
36
67
|
|
|
37
68
|
# Returns the clockwise line angle in degrees.
|
|
38
69
|
# @return [Float]
|
|
@@ -42,21 +73,19 @@ module Sevgi
|
|
|
42
73
|
# @return [Sevgi::Geometry::Point]
|
|
43
74
|
def ending = points.last
|
|
44
75
|
|
|
45
|
-
# Reports whether a point is left of the line
|
|
76
|
+
# Reports whether a point is left of the directed line from {#starting} to {#ending} in screen coordinates.
|
|
77
|
+
# Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
|
|
46
78
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
47
79
|
# @return [Boolean]
|
|
48
80
|
# @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
|
|
81
|
+
def left?(point) = F.lt?(side(point), 0.0)
|
|
54
82
|
|
|
55
|
-
# Reports whether a point is right of the line
|
|
83
|
+
# Reports whether a point is right of the directed line from {#starting} to {#ending} in screen coordinates.
|
|
84
|
+
# Points on the infinite line are on neither side. A zero-length line has no direction and returns false.
|
|
56
85
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
57
86
|
# @return [Boolean]
|
|
58
87
|
# @raise [Sevgi::Geometry::Error] when point cannot be coerced
|
|
59
|
-
def right?(point) =
|
|
88
|
+
def right?(point) = F.gt?(side(point), 0.0)
|
|
60
89
|
|
|
61
90
|
# Returns the starting point.
|
|
62
91
|
# @return [Sevgi::Geometry::Point]
|
|
@@ -67,6 +96,8 @@ module Sevgi
|
|
|
67
96
|
# @return [Object] graphics node command result
|
|
68
97
|
def draw!(node, **) = node.LineTo(x1: position.x, y1: position.y, x2: ending.x, y2: ending.y, **)
|
|
69
98
|
|
|
99
|
+
private :draw!
|
|
100
|
+
|
|
70
101
|
# Reports whether a point lies on the finite line segment.
|
|
71
102
|
# @param point [Sevgi::Geometry::Point, Array<Numeric>] point to test
|
|
72
103
|
# @return [Boolean]
|
|
@@ -78,12 +109,26 @@ module Sevgi
|
|
|
78
109
|
end
|
|
79
110
|
|
|
80
111
|
# Returns a parallel line shifted by a signed perpendicular offset.
|
|
112
|
+
# Positive distance moves to the directed line's left in screen coordinates; reversing the endpoints reverses the
|
|
113
|
+
# shift direction.
|
|
81
114
|
# @param distance [Numeric] signed perpendicular offset
|
|
82
115
|
# @return [Sevgi::Geometry::Line]
|
|
83
|
-
|
|
116
|
+
# @raise [Sevgi::Geometry::Error] when distance is not a finite real number
|
|
117
|
+
def shift(distance)
|
|
118
|
+
distance = Real[:distance, distance]
|
|
119
|
+
translate(distance * F.sin(angle), -distance * F.cos(angle))
|
|
120
|
+
end
|
|
84
121
|
|
|
85
122
|
private
|
|
86
123
|
|
|
124
|
+
def cross(ax, ay, bx, by) = (ax * by) - (ay * bx)
|
|
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,134 @@ 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, b))
|
|
131
|
+
|
|
132
|
+
Error.("Parallelogram sides must be non-degenerate opposite pairs") unless valid
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def cross(a, b) = (a.x * b.y) - (a.y * b.x)
|
|
136
|
+
|
|
137
|
+
def opposite?(a, b) = F.zero?(a.x + b.x) && F.zero?(a.y + b.y)
|
|
57
138
|
end
|
|
58
139
|
end
|
|
59
140
|
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
|
|
@@ -7,8 +7,43 @@ module Sevgi
|
|
|
7
7
|
PolylineBase = Element.lined(open: true)
|
|
8
8
|
private_constant :PolylineBase
|
|
9
9
|
|
|
10
|
-
# Variable-size open lined element.
|
|
10
|
+
# Variable-size open lined element with at least two points.
|
|
11
|
+
# @!method self.[](*segments, position: Origin)
|
|
12
|
+
# Builds a polyline from ordered segments.
|
|
13
|
+
# @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] ordered segments
|
|
14
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
15
|
+
# @return [Sevgi::Geometry::Polyline]
|
|
16
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
|
|
17
|
+
# @!method self.call(*points)
|
|
18
|
+
# Builds a polyline from ordered points.
|
|
19
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] ordered points
|
|
20
|
+
# @return [Sevgi::Geometry::Polyline]
|
|
21
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
|
|
22
|
+
# @!method self.from_segments(*segments, position: Origin)
|
|
23
|
+
# Builds a polyline from ordered segments.
|
|
24
|
+
# @param segments [Array<Sevgi::Geometry::Segment, Array<Numeric>>] ordered segments
|
|
25
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] starting point
|
|
26
|
+
# @return [Sevgi::Geometry::Polyline]
|
|
27
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
|
|
28
|
+
# @!method self.from_points(*points)
|
|
29
|
+
# Builds a polyline from ordered points.
|
|
30
|
+
# @param points [Array<Sevgi::Geometry::Point, Array<Numeric>>] ordered points
|
|
31
|
+
# @return [Sevgi::Geometry::Polyline]
|
|
32
|
+
# @raise [Sevgi::Geometry::Error] when inputs cannot be coerced or do not form a polyline
|
|
33
|
+
# @example Pair mathematical notation with English conveniences
|
|
34
|
+
# Sevgi::Geometry::Polyline[[2, 0], [1, 90]] == Sevgi::Geometry::Polyline.from_segments([2, 0], [1, 90])
|
|
35
|
+
# Sevgi::Geometry::Polyline.([0, 0], [2, 0]) == Sevgi::Geometry::Polyline.from_points([0, 0], [2, 0])
|
|
36
|
+
# @example Measure and query an open path
|
|
37
|
+
# path = Sevgi::Geometry::Polyline.([0, 0], [3, 0], [3, 4])
|
|
38
|
+
# path.length # => 7.0
|
|
39
|
+
# path.on?([2, 0]) # => true
|
|
40
|
+
# path.inside?([1, 1]) # => false
|
|
11
41
|
class Polyline < PolylineBase
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def validate_geometry!
|
|
45
|
+
Error.("Polyline requires at least two points") if points.size < 2
|
|
46
|
+
end
|
|
12
47
|
end
|
|
13
48
|
end
|
|
14
49
|
end
|
|
@@ -7,7 +7,36 @@ module Sevgi
|
|
|
7
7
|
RectBase = Element.lined(4)
|
|
8
8
|
private_constant :RectBase
|
|
9
9
|
|
|
10
|
-
# Closed four-sided rectangle aligned to the screen axes.
|
|
10
|
+
# Closed four-sided rectangle aligned to the screen axes. Affine operations return Rect while the result remains
|
|
11
|
+
# axis-aligned and widen to {Parallelogram} after rotation or skew changes that category. A Square similarly widens
|
|
12
|
+
# to Rect after unequal scaling.
|
|
13
|
+
# @example Inspect corners, sides, and containment
|
|
14
|
+
# rect = Sevgi::Geometry::Rect[8, 4, position: [2, 3]]
|
|
15
|
+
# rect.top_left.deconstruct # => [2.0, 3.0]
|
|
16
|
+
# rect.right.length # => 4.0
|
|
17
|
+
# rect.inside?([5, 5]) # => true
|
|
18
|
+
# @example Observe semantic widening after affine transforms
|
|
19
|
+
# Sevgi::Geometry::Rect[8, 4].rotate(30).class # => Sevgi::Geometry::Parallelogram
|
|
20
|
+
# Sevgi::Geometry::Square[4].scale(2, 1).class # => Sevgi::Geometry::Rect
|
|
21
|
+
# @!attribute [r] A
|
|
22
|
+
# @return [Sevgi::Geometry::Point] top-left vertex
|
|
23
|
+
# @!attribute [r] B
|
|
24
|
+
# @return [Sevgi::Geometry::Point] top-right vertex
|
|
25
|
+
# @!attribute [r] C
|
|
26
|
+
# @return [Sevgi::Geometry::Point] bottom-right vertex
|
|
27
|
+
# @!attribute [r] D
|
|
28
|
+
# @return [Sevgi::Geometry::Point] bottom-left vertex
|
|
29
|
+
# @!attribute [r] AB
|
|
30
|
+
# @return [Sevgi::Geometry::Line] top side
|
|
31
|
+
# @!attribute [r] BC
|
|
32
|
+
# @return [Sevgi::Geometry::Line] right side
|
|
33
|
+
# @!attribute [r] CD
|
|
34
|
+
# @return [Sevgi::Geometry::Line] bottom side
|
|
35
|
+
# @!attribute [r] DA
|
|
36
|
+
# @return [Sevgi::Geometry::Line] left side
|
|
37
|
+
# @!method perimeter
|
|
38
|
+
# Returns the closed path perimeter.
|
|
39
|
+
# @return [Float]
|
|
11
40
|
class Rect < RectBase
|
|
12
41
|
# @overload [](width, height, position: Origin)
|
|
13
42
|
# Builds a rectangle from size and top-left position.
|
|
@@ -15,8 +44,30 @@ module Sevgi
|
|
|
15
44
|
# @param height [Numeric] rectangle height
|
|
16
45
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
|
|
17
46
|
# @return [Sevgi::Geometry::Rect]
|
|
18
|
-
# @raise [Sevgi::Geometry::Error] when position cannot be coerced
|
|
19
|
-
|
|
47
|
+
# @raise [Sevgi::Geometry::Error] when position cannot be coerced or a dimension is negative
|
|
48
|
+
# @example Mathematical notation and English convenience are equivalent
|
|
49
|
+
# Sevgi::Geometry::Rect[3, 4] == Sevgi::Geometry::Rect.from_size(3, 4)
|
|
50
|
+
def self.[](width, height, position: Origin) = construct(width, height, position:)
|
|
51
|
+
|
|
52
|
+
# Constructs a rectangle for canonical size notation.
|
|
53
|
+
# @param width [Numeric] rectangle width
|
|
54
|
+
# @param height [Numeric] rectangle height
|
|
55
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
|
|
56
|
+
# @return [Sevgi::Geometry::Rect]
|
|
57
|
+
# @raise [Sevgi::Geometry::Error] when position or a dimension is invalid
|
|
58
|
+
# @api private
|
|
59
|
+
def self.construct(width, height, position:)
|
|
60
|
+
width = dimension!(:width, width)
|
|
61
|
+
height = dimension!(:height, height)
|
|
62
|
+
|
|
63
|
+
new_by_segments(
|
|
64
|
+
Segment.rightward(width),
|
|
65
|
+
Segment.downward(height),
|
|
66
|
+
Segment.leftward(width),
|
|
67
|
+
Segment.upward(height),
|
|
68
|
+
position:
|
|
69
|
+
)
|
|
70
|
+
end
|
|
20
71
|
|
|
21
72
|
# @overload call(top_left, bottom_right)
|
|
22
73
|
# Builds a rectangle from two opposite corners.
|
|
@@ -24,46 +75,92 @@ module Sevgi
|
|
|
24
75
|
# @param bottom_right [Sevgi::Geometry::Point, Array<Numeric>] bottom-right corner
|
|
25
76
|
# @return [Sevgi::Geometry::Rect]
|
|
26
77
|
# @raise [Sevgi::Geometry::Error] when either point cannot be coerced
|
|
27
|
-
|
|
78
|
+
# @example Mathematical notation and English convenience are equivalent
|
|
79
|
+
# Sevgi::Geometry::Rect.([0, 0], [3, 4]) == Sevgi::Geometry::Rect.from_corners([0, 0], [3, 4])
|
|
80
|
+
def self.call(top_left, bottom_right)
|
|
81
|
+
top_left, bottom_right = Tuples[Point, top_left, bottom_right]
|
|
82
|
+
left, right = [top_left.x, bottom_right.x].minmax
|
|
83
|
+
top, bottom = [top_left.y, bottom_right.y].minmax
|
|
84
|
+
width, height = right - left, bottom - top
|
|
85
|
+
|
|
86
|
+
if self <= Square
|
|
87
|
+
Error.("Square corners must define equal dimensions") unless F.eq?(width, height)
|
|
88
|
+
|
|
89
|
+
return self[width, position: [left, top]]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
self[width, height, position: [left, top]]
|
|
93
|
+
end
|
|
28
94
|
|
|
29
95
|
# Builds a rectangle from two opposite corners.
|
|
30
96
|
# @param top_left [Sevgi::Geometry::Point, Array<Numeric>] top-left corner
|
|
31
97
|
# @param bottom_right [Sevgi::Geometry::Point, Array<Numeric>] bottom-right corner
|
|
32
98
|
# @return [Sevgi::Geometry::Rect]
|
|
33
99
|
# @raise [Sevgi::Geometry::Error] when either point cannot be coerced
|
|
34
|
-
def self.from_corners(top_left, bottom_right)
|
|
35
|
-
top_left, bottom_right = Tuples[Point, top_left, bottom_right]
|
|
36
|
-
width = (bottom_right.x - top_left.x).abs
|
|
37
|
-
|
|
38
|
-
new_by_points(
|
|
39
|
-
top_left,
|
|
40
|
-
top_left.translate(width, 0.0),
|
|
41
|
-
bottom_right,
|
|
42
|
-
bottom_right.translate(-width, 0.0)
|
|
43
|
-
)
|
|
44
|
-
end
|
|
100
|
+
def self.from_corners(top_left, bottom_right) = call(top_left, bottom_right)
|
|
45
101
|
|
|
46
102
|
# Builds a rectangle from size and top-left position.
|
|
47
103
|
# @param width [Numeric] rectangle width
|
|
48
104
|
# @param height [Numeric] rectangle height
|
|
49
105
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
|
|
50
106
|
# @return [Sevgi::Geometry::Rect]
|
|
51
|
-
# @raise [Sevgi::Geometry::Error] when position cannot be coerced
|
|
52
|
-
def self.from_size(width, height, position: Origin)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
107
|
+
# @raise [Sevgi::Geometry::Error] when position cannot be coerced or a dimension is negative
|
|
108
|
+
def self.from_size(width, height, position: Origin) = self[width, height, position:]
|
|
109
|
+
|
|
110
|
+
class << self
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def affine(*points)
|
|
114
|
+
left, right, top, bottom = bounds(points)
|
|
115
|
+
width, height = right - left, bottom - top
|
|
116
|
+
|
|
117
|
+
unless axis_aligned?(points, left, right, top, bottom)
|
|
118
|
+
return Parallelogram.call(*points.first(4))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
klass = self <= Square && F.eq?(width, height) ? Square : Rect
|
|
122
|
+
return klass[width, position: [left, top]] if klass == Square
|
|
123
|
+
|
|
124
|
+
klass[width, height, position: [left, top]]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def approximate(*points)
|
|
128
|
+
new_by_points!(*points)
|
|
129
|
+
rescue Error
|
|
130
|
+
return Rect.send(:new_by_points!, *points) if self <= Square
|
|
131
|
+
|
|
132
|
+
super
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def axis_aligned?(points, left, right, top, bottom)
|
|
136
|
+
expected = [[left, top], [right, top], [right, bottom], [left, bottom]]
|
|
137
|
+
vertices = points.first(4)
|
|
138
|
+
|
|
139
|
+
expected.all? { |corner| vertices.any? { it.eq?(Point[*corner]) } }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def bounds(points)
|
|
143
|
+
vertices = points.first(4)
|
|
144
|
+
[vertices.map(&:x).min, vertices.map(&:x).max, vertices.map(&:y).min, vertices.map(&:y).max]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def dimension!(name, value)
|
|
148
|
+
value = Real[name, value]
|
|
149
|
+
Error.("Rectangle #{name} cannot be negative") if value.negative?
|
|
150
|
+
|
|
151
|
+
value
|
|
152
|
+
end
|
|
60
153
|
end
|
|
61
154
|
|
|
155
|
+
private_class_method :construct, :from_points, :from_segments
|
|
156
|
+
|
|
62
157
|
# Draws the rectangle into a graphics node.
|
|
63
158
|
# @param node [Object] graphics node receiving the drawing command
|
|
64
159
|
# @return [Object] graphics node command result
|
|
65
160
|
def draw!(node, **) = node.rect(x: position.x, y: position.y, width: width, height: height, **)
|
|
66
161
|
|
|
162
|
+
private :draw!
|
|
163
|
+
|
|
67
164
|
# Returns rectangle height.
|
|
68
165
|
# @return [Float]
|
|
69
166
|
def height = @height ||= segments[1].length
|
|
@@ -111,19 +208,52 @@ module Sevgi
|
|
|
111
208
|
%i[top right bottom left].each_with_index do |side, i|
|
|
112
209
|
define_method(side) { lines[i] }
|
|
113
210
|
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def validate_geometry!
|
|
215
|
+
left, right, top, bottom = self.class.send(:bounds, points)
|
|
216
|
+
expected = [[left, top], [right, top], [right, bottom], [left, bottom], [left, top]]
|
|
217
|
+
valid = points.zip(expected).all? { |point, pair| point.eq?(Point[*pair]) }
|
|
218
|
+
|
|
219
|
+
Error.("Rectangle points must form an axis-aligned rectangle") unless valid
|
|
220
|
+
end
|
|
114
221
|
end
|
|
115
222
|
|
|
116
|
-
# Rectangle with equal width and height.
|
|
223
|
+
# Rectangle with equal width and height. Use {#width} or {#height} for its side length; inherited
|
|
224
|
+
# {Element::Lined#length} returns the complete path length.
|
|
225
|
+
# @example Construct the same square from opposite corners
|
|
226
|
+
# Sevgi::Geometry::Square.([0, 0], [5, 5]) == Sevgi::Geometry::Square.from_corners([0, 0], [5, 5])
|
|
117
227
|
class Square < Rect
|
|
118
|
-
#
|
|
119
|
-
|
|
228
|
+
# Builds a square from side length and top-left position.
|
|
229
|
+
# @param length [Numeric] side length
|
|
230
|
+
# @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
|
|
231
|
+
# @return [Sevgi::Geometry::Square]
|
|
232
|
+
# @raise [Sevgi::Geometry::Error] when position cannot be coerced or length is negative
|
|
233
|
+
# @example Mathematical notation and English convenience are equivalent
|
|
234
|
+
# Sevgi::Geometry::Square[5] == Sevgi::Geometry::Square.from_size(5)
|
|
235
|
+
def self.[](length, position: Origin) = construct(length, length, position:)
|
|
236
|
+
|
|
237
|
+
# Builds a square from two opposite corners.
|
|
238
|
+
# @param top_left [Sevgi::Geometry::Point, Array<Numeric>] top-left corner
|
|
239
|
+
# @param bottom_right [Sevgi::Geometry::Point, Array<Numeric>] bottom-right corner
|
|
240
|
+
# @return [Sevgi::Geometry::Square]
|
|
241
|
+
# @raise [Sevgi::Geometry::Error] when points are invalid or define unequal dimensions
|
|
242
|
+
def self.from_corners(top_left, bottom_right) = call(top_left, bottom_right)
|
|
120
243
|
|
|
121
244
|
# Builds a square from side length and top-left position.
|
|
122
245
|
# @param length [Numeric] side length
|
|
123
246
|
# @param position [Sevgi::Geometry::Point, Array<Numeric>] top-left position
|
|
124
247
|
# @return [Sevgi::Geometry::Square]
|
|
125
|
-
# @raise [Sevgi::Geometry::Error] when position
|
|
126
|
-
def self.
|
|
248
|
+
# @raise [Sevgi::Geometry::Error] when position or length is invalid
|
|
249
|
+
def self.from_size(length, position: Origin) = self[length, position:]
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
def validate_geometry!
|
|
254
|
+
super
|
|
255
|
+
Error.("Square sides must have equal length") unless F.eq?(width, height)
|
|
256
|
+
end
|
|
127
257
|
end
|
|
128
258
|
end
|
|
129
259
|
end
|