clipper2 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/examples/larb_transform.rb +17 -0
- data/examples/rugl_mesh.rb +33 -0
- data/examples/svg_boolean.rb +28 -0
- data/ext/clipper2/CMakeLists.txt +27 -0
- data/ext/clipper2/clipper2_native.cpp +2 -0
- data/ext/clipper2/extconf.rb +27 -0
- data/ext/clipper2/generate_bindings.rb +73 -0
- data/ext/clipper2/memory_check.cpp +48 -0
- data/ext/clipper2/vendor/LICENSE +23 -0
- data/ext/clipper2/vendor/UPSTREAM.md +11 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.core.h +1159 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.engine.h +635 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.export.h +851 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.h +796 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.minkowski.h +117 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.offset.h +125 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.rectclip.h +80 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.triangulation.h +27 -0
- data/ext/clipper2/vendor/include/clipper2/clipper.version.h +6 -0
- data/ext/clipper2/vendor/src/clipper.engine.cpp +3152 -0
- data/ext/clipper2/vendor/src/clipper.offset.cpp +661 -0
- data/ext/clipper2/vendor/src/clipper.rectclip.cpp +1027 -0
- data/ext/clipper2/vendor/src/clipper.triangulation.cpp +1221 -0
- data/lib/clipper2/api.rb +45 -0
- data/lib/clipper2/c_paths64.rb +80 -0
- data/lib/clipper2/earcut/engine.rb +191 -0
- data/lib/clipper2/earcut/geometry.rb +100 -0
- data/lib/clipper2/earcut/node.rb +18 -0
- data/lib/clipper2/earcut/ring.rb +94 -0
- data/lib/clipper2/earcut.rb +18 -0
- data/lib/clipper2/errors.rb +19 -0
- data/lib/clipper2/generated.rb +62 -0
- data/lib/clipper2/native.rb +38 -0
- data/lib/clipper2/native_memory.rb +64 -0
- data/lib/clipper2/operations.rb +206 -0
- data/lib/clipper2/path.rb +171 -0
- data/lib/clipper2/path_simplifier.rb +61 -0
- data/lib/clipper2/paths.rb +130 -0
- data/lib/clipper2/quantizer.rb +48 -0
- data/lib/clipper2/triangulation.rb +103 -0
- data/lib/clipper2/version.rb +5 -0
- data/lib/clipper2.rb +34 -0
- data/licenses/CLIPPER2-BSL-1.0.txt +23 -0
- data/licenses/EARCUT-ISC.txt +15 -0
- metadata +113 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clipper2
|
|
4
|
+
module NativeMemory
|
|
5
|
+
MAX_COORD = (2**61) - 1
|
|
6
|
+
MIN_COORD = -MAX_COORD
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def paths_pointer(paths)
|
|
11
|
+
return FFI::Pointer::NULL if paths.empty?
|
|
12
|
+
|
|
13
|
+
paths.each { |path| validate_native_range(path) }
|
|
14
|
+
pointer_from_bytes(CPaths64.pack(paths))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def path_pointer(path)
|
|
18
|
+
validate_native_range(path)
|
|
19
|
+
pointer_from_bytes(CPaths64.pack_path(path))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def int64_pointer(values)
|
|
23
|
+
pointer_from_bytes(values.pack("q<*"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pointer_reference(pointer = FFI::Pointer::NULL)
|
|
27
|
+
reference = FFI::MemoryPointer.new(:pointer)
|
|
28
|
+
reference.write_pointer(pointer)
|
|
29
|
+
reference.instance_variable_set(:@clipper2_owner, pointer)
|
|
30
|
+
reference
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_paths(pointer, scale:)
|
|
34
|
+
CPaths64.read(pointer, scale: scale)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dispose_pointer(pointer)
|
|
38
|
+
return if pointer.nil? || pointer.null?
|
|
39
|
+
|
|
40
|
+
dispose_reference(pointer_reference(pointer))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dispose_reference(reference)
|
|
44
|
+
return if reference.nil? || reference.read_pointer.null?
|
|
45
|
+
|
|
46
|
+
Native.send(:DisposeArray64, reference)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_native_range(path)
|
|
50
|
+
path.send(:quantized_coords).each do |coordinate|
|
|
51
|
+
next if coordinate.between?(MIN_COORD, MAX_COORD)
|
|
52
|
+
|
|
53
|
+
raise RangeError, "quantized coordinate exceeds Clipper2's supported range"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pointer_from_bytes(bytes)
|
|
58
|
+
pointer = FFI::MemoryPointer.new(:int64, bytes.bytesize / 8)
|
|
59
|
+
pointer.put_bytes(0, bytes)
|
|
60
|
+
pointer
|
|
61
|
+
end
|
|
62
|
+
private_class_method :pointer_from_bytes
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clipper2
|
|
4
|
+
module Operations
|
|
5
|
+
NATIVE_ERRORS = {
|
|
6
|
+
-1 => "Clipper2 could not complete the clipping operation",
|
|
7
|
+
-3 => "Clipper2 rejected the fill rule",
|
|
8
|
+
-4 => "Clipper2 rejected the clip type"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def boolean(type, subject, clip = nil, subject_open: nil, fill_rule: :non_zero,
|
|
14
|
+
preserve_collinear: true, reverse_solution: false)
|
|
15
|
+
scale = common_scale(subject, clip, subject_open)
|
|
16
|
+
subjects = Paths.coerce(subject, scale: scale)
|
|
17
|
+
clips = Paths.coerce(clip, scale: scale)
|
|
18
|
+
open_subjects = Paths.coerce(subject_open, scale: scale)
|
|
19
|
+
clip_type = enum_value(Generated::CLIP_TYPES, normalize_clip_type(type), "clip type")
|
|
20
|
+
fill = enum_value(Generated::FILL_RULES, fill_rule, "fill rule")
|
|
21
|
+
|
|
22
|
+
closed_reference = NativeMemory.pointer_reference
|
|
23
|
+
open_reference = NativeMemory.pointer_reference
|
|
24
|
+
status = Native.send(
|
|
25
|
+
:BooleanOp64,
|
|
26
|
+
clip_type,
|
|
27
|
+
fill,
|
|
28
|
+
NativeMemory.paths_pointer(subjects),
|
|
29
|
+
NativeMemory.paths_pointer(open_subjects),
|
|
30
|
+
NativeMemory.paths_pointer(clips),
|
|
31
|
+
closed_reference,
|
|
32
|
+
open_reference,
|
|
33
|
+
preserve_collinear,
|
|
34
|
+
reverse_solution
|
|
35
|
+
)
|
|
36
|
+
raise_native_error(status) unless status.zero?
|
|
37
|
+
|
|
38
|
+
[
|
|
39
|
+
NativeMemory.read_paths(closed_reference.read_pointer, scale: scale),
|
|
40
|
+
NativeMemory.read_paths(open_reference.read_pointer, scale: scale)
|
|
41
|
+
]
|
|
42
|
+
ensure
|
|
43
|
+
NativeMemory.dispose_reference(closed_reference)
|
|
44
|
+
NativeMemory.dispose_reference(open_reference)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inflate(value, delta, join: :round, end_type: :polygon, miter_limit: 2.0,
|
|
48
|
+
arc_tolerance: 0.25, reverse_solution: false)
|
|
49
|
+
paths = Paths.coerce(value, scale: common_scale(value))
|
|
50
|
+
return Paths.new([], scale: paths.scale) if paths.empty?
|
|
51
|
+
|
|
52
|
+
scaled_delta = scaled_distance(delta, paths.scale, "delta")
|
|
53
|
+
scaled_tolerance = scaled_distance(arc_tolerance, paths.scale, "arc_tolerance", allow_zero: true)
|
|
54
|
+
miter = positive_float(miter_limit, "miter_limit")
|
|
55
|
+
join_value = enum_value(Generated::JOIN_TYPES, join, "join type")
|
|
56
|
+
end_value = enum_value(Generated::END_TYPES, end_type, "end type")
|
|
57
|
+
|
|
58
|
+
pointer = Native.send(
|
|
59
|
+
:InflatePaths64,
|
|
60
|
+
NativeMemory.paths_pointer(paths),
|
|
61
|
+
scaled_delta,
|
|
62
|
+
join_value,
|
|
63
|
+
end_value,
|
|
64
|
+
miter,
|
|
65
|
+
scaled_tolerance,
|
|
66
|
+
reverse_solution
|
|
67
|
+
)
|
|
68
|
+
NativeMemory.read_paths(pointer, scale: paths.scale)
|
|
69
|
+
ensure
|
|
70
|
+
NativeMemory.dispose_pointer(pointer)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def rect_clip(value, rect, lines: false)
|
|
74
|
+
paths = Paths.coerce(value, scale: common_scale(value))
|
|
75
|
+
return Paths.new([], scale: paths.scale) if paths.empty?
|
|
76
|
+
|
|
77
|
+
rectangle = quantize_rectangle(rect, paths.scale)
|
|
78
|
+
rectangle_pointer = NativeMemory.int64_pointer(rectangle)
|
|
79
|
+
function = lines ? :RectClipLines64 : :RectClip64
|
|
80
|
+
pointer = Native.send(function, rectangle_pointer, NativeMemory.paths_pointer(paths))
|
|
81
|
+
NativeMemory.read_paths(pointer, scale: paths.scale)
|
|
82
|
+
ensure
|
|
83
|
+
NativeMemory.dispose_pointer(pointer)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def minkowski_sum(pattern, path, closed: true)
|
|
87
|
+
minkowski(:MinkowskiSum64, pattern, path, closed: closed)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def minkowski_difference(pattern, path, closed: true)
|
|
91
|
+
minkowski(:MinkowskiDiff64, pattern, path, closed: closed)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def minkowski(function, pattern, path, closed:)
|
|
95
|
+
scale = common_scale(pattern, path)
|
|
96
|
+
pattern_path = single_path(pattern, scale, "pattern")
|
|
97
|
+
target_path = single_path(path, scale, "path")
|
|
98
|
+
return Paths.new([], scale: scale) if pattern_path.empty? || target_path.empty?
|
|
99
|
+
|
|
100
|
+
pattern_reference = NativeMemory.pointer_reference(NativeMemory.path_pointer(pattern_path))
|
|
101
|
+
path_reference = NativeMemory.pointer_reference(NativeMemory.path_pointer(target_path))
|
|
102
|
+
pointer = Native.send(function, pattern_reference, path_reference, closed)
|
|
103
|
+
NativeMemory.read_paths(pointer, scale: scale)
|
|
104
|
+
ensure
|
|
105
|
+
NativeMemory.dispose_pointer(pointer)
|
|
106
|
+
end
|
|
107
|
+
private_class_method :minkowski
|
|
108
|
+
|
|
109
|
+
def single_path(value, scale, name)
|
|
110
|
+
paths = Paths.coerce(value, scale: scale)
|
|
111
|
+
raise ArgumentError, "#{name} must contain exactly one path" unless paths.size == 1
|
|
112
|
+
|
|
113
|
+
paths.first
|
|
114
|
+
end
|
|
115
|
+
private_class_method :single_path
|
|
116
|
+
|
|
117
|
+
def common_scale(*values)
|
|
118
|
+
scales = []
|
|
119
|
+
values.each { |value| collect_scales(value, scales) }
|
|
120
|
+
scales.uniq!
|
|
121
|
+
raise ScaleError, "all operands must use the same scale" if scales.length > 1
|
|
122
|
+
|
|
123
|
+
scales.first || Clipper2.default_scale
|
|
124
|
+
end
|
|
125
|
+
private_class_method :common_scale
|
|
126
|
+
|
|
127
|
+
def collect_scales(value, scales)
|
|
128
|
+
case value
|
|
129
|
+
when Path, Paths
|
|
130
|
+
scales << value.scale
|
|
131
|
+
when Array
|
|
132
|
+
value.each { |entry| collect_scales(entry, scales) }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
private_class_method :collect_scales
|
|
136
|
+
|
|
137
|
+
def normalize_clip_type(type)
|
|
138
|
+
type.to_sym == :intersect ? :intersection : type
|
|
139
|
+
rescue NoMethodError
|
|
140
|
+
type
|
|
141
|
+
end
|
|
142
|
+
private_class_method :normalize_clip_type
|
|
143
|
+
|
|
144
|
+
def enum_value(values, value, name)
|
|
145
|
+
key = value.to_sym
|
|
146
|
+
values.fetch(key)
|
|
147
|
+
rescue NoMethodError, KeyError
|
|
148
|
+
raise ArgumentError, "unknown #{name} #{value.inspect}; expected one of #{values.keys.join(', ')}"
|
|
149
|
+
end
|
|
150
|
+
private_class_method :enum_value
|
|
151
|
+
|
|
152
|
+
def scaled_distance(value, scale, name, allow_zero: false)
|
|
153
|
+
numeric = Float(value)
|
|
154
|
+
valid_sign = allow_zero ? numeric >= 0 : !numeric.zero?
|
|
155
|
+
unless numeric.finite? && valid_sign
|
|
156
|
+
raise ArgumentError, "#{name} must be #{allow_zero ? 'non-negative' : 'non-zero'} and finite"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
scaled = numeric * scale
|
|
160
|
+
unless scaled.finite? && scaled.between?(NativeMemory::MIN_COORD, NativeMemory::MAX_COORD)
|
|
161
|
+
raise RangeError, "#{name} exceeds Clipper2's supported coordinate range"
|
|
162
|
+
end
|
|
163
|
+
scaled
|
|
164
|
+
rescue TypeError, ArgumentError => error
|
|
165
|
+
raise error if error.is_a?(Clipper2::RangeError) || error.message.start_with?(name)
|
|
166
|
+
|
|
167
|
+
raise ArgumentError, "#{name} must be numeric"
|
|
168
|
+
end
|
|
169
|
+
private_class_method :scaled_distance
|
|
170
|
+
|
|
171
|
+
def positive_float(value, name)
|
|
172
|
+
numeric = Float(value)
|
|
173
|
+
raise ArgumentError, "#{name} must be positive and finite" unless numeric.finite? && numeric.positive?
|
|
174
|
+
|
|
175
|
+
numeric
|
|
176
|
+
rescue TypeError, ArgumentError
|
|
177
|
+
raise ArgumentError, "#{name} must be positive and finite"
|
|
178
|
+
end
|
|
179
|
+
private_class_method :positive_float
|
|
180
|
+
|
|
181
|
+
def quantize_rectangle(rect, scale)
|
|
182
|
+
values = rect.respond_to?(:to_a) ? rect.to_a : []
|
|
183
|
+
raise ArgumentError, "rect must be [min_x, min_y, max_x, max_y]" unless values.length == 4
|
|
184
|
+
|
|
185
|
+
rectangle = values.map { |coordinate| Quantizer.quantize(coordinate, scale) }
|
|
186
|
+
rectangle.each do |coordinate|
|
|
187
|
+
unless coordinate.between?(NativeMemory::MIN_COORD, NativeMemory::MAX_COORD)
|
|
188
|
+
raise RangeError, "rect exceeds Clipper2's supported coordinate range"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
unless rectangle[2] > rectangle[0] && rectangle[3] > rectangle[1]
|
|
192
|
+
raise ArgumentError, "rect must have positive width and height"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
rectangle
|
|
196
|
+
end
|
|
197
|
+
private_class_method :quantize_rectangle
|
|
198
|
+
|
|
199
|
+
def raise_native_error(status)
|
|
200
|
+
message = NATIVE_ERRORS.fetch(status, "Clipper2 returned an unknown native error")
|
|
201
|
+
raise NativeError.new(message, code: status)
|
|
202
|
+
end
|
|
203
|
+
private_class_method :raise_native_error
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clipper2
|
|
4
|
+
class Path
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def [](*points, scale: Clipper2.default_scale)
|
|
9
|
+
points = points.first if points.length == 1 && point_collection?(points.first)
|
|
10
|
+
new(points, scale: scale)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def from_coords(coords, scale:)
|
|
14
|
+
new(nil, scale: scale, coords: coords)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def point?(value)
|
|
18
|
+
value.respond_to?(:size) && value.respond_to?(:[]) && value.size == 2 &&
|
|
19
|
+
value[0].is_a?(Numeric) && value[1].is_a?(Numeric)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def point_collection?(value)
|
|
23
|
+
value.is_a?(Array) && (value.empty? || value.all? { |point| point?(point) })
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :scale
|
|
28
|
+
|
|
29
|
+
def initialize(points = nil, scale: Clipper2.default_scale, coords: nil)
|
|
30
|
+
@scale = Quantizer.validate_scale(scale)
|
|
31
|
+
@coords = coords ? validate_coords(coords) : quantize_points(points || [])
|
|
32
|
+
@coords.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def each
|
|
37
|
+
return enum_for(__method__) unless block_given?
|
|
38
|
+
|
|
39
|
+
@coords.each_slice(2) do |x, y|
|
|
40
|
+
yield [Quantizer.dequantize(x, scale), Quantizer.dequantize(y, scale)]
|
|
41
|
+
end
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def [](index)
|
|
46
|
+
x = @coords.fetch(index * 2)
|
|
47
|
+
y = @coords.fetch((index * 2) + 1)
|
|
48
|
+
[Quantizer.dequantize(x, scale), Quantizer.dequantize(y, scale)]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def size
|
|
52
|
+
@coords.length / 2
|
|
53
|
+
end
|
|
54
|
+
alias length size
|
|
55
|
+
|
|
56
|
+
def empty?
|
|
57
|
+
@coords.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def area
|
|
61
|
+
return 0.0 if size < 3
|
|
62
|
+
|
|
63
|
+
twice_area = 0
|
|
64
|
+
each_quantized_edge do |x1, y1, x2, y2|
|
|
65
|
+
twice_area += (x1 * y2) - (x2 * y1)
|
|
66
|
+
end
|
|
67
|
+
twice_area.fdiv(2 * scale * scale)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def orientation
|
|
71
|
+
area.negative? ? :cw : :ccw
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reverse
|
|
75
|
+
reversed = @coords.each_slice(2).to_a.reverse.flatten
|
|
76
|
+
self.class.from_coords(reversed, scale: scale)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def bounds
|
|
80
|
+
return nil if empty?
|
|
81
|
+
|
|
82
|
+
xs = @coords.each_slice(2).map(&:first)
|
|
83
|
+
ys = @coords.each_slice(2).map(&:last)
|
|
84
|
+
[xs.min, ys.min, xs.max, ys.max].map { |value| Quantizer.dequantize(value, scale) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def contains?(point)
|
|
88
|
+
return false if size < 3
|
|
89
|
+
|
|
90
|
+
px, py = Quantizer.quantize_point(point, scale)
|
|
91
|
+
inside = false
|
|
92
|
+
|
|
93
|
+
each_quantized_edge do |x1, y1, x2, y2|
|
|
94
|
+
return true if point_on_segment?(px, py, x1, y1, x2, y2)
|
|
95
|
+
next if (y1 > py) == (y2 > py)
|
|
96
|
+
|
|
97
|
+
lhs = (x2 - x1) * (py - y1)
|
|
98
|
+
rhs = (px - x1) * (y2 - y1)
|
|
99
|
+
inside = !inside if y2 > y1 ? lhs > rhs : lhs < rhs
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
inside
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def to_packed
|
|
106
|
+
to_a.flatten.pack("e*")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def simplify(epsilon)
|
|
110
|
+
PathSimplifier.call(self, epsilon)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def ==(other)
|
|
114
|
+
other.is_a?(self.class) && scale == other.scale && @coords == other.quantized_coords
|
|
115
|
+
end
|
|
116
|
+
alias eql? ==
|
|
117
|
+
|
|
118
|
+
def hash
|
|
119
|
+
[self.class, scale, @coords].hash
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def inspect
|
|
123
|
+
"#<#{self.class.name} scale=#{scale.inspect} points=#{to_a.inspect}>"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
protected
|
|
127
|
+
|
|
128
|
+
def quantized_coords
|
|
129
|
+
@coords
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def quantize_points(points)
|
|
135
|
+
unless points.respond_to?(:each)
|
|
136
|
+
raise ArgumentError, "points must be an enumerable of [x, y] pairs"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
points.flat_map { |point| Quantizer.quantize_point(point, scale) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_coords(coords)
|
|
143
|
+
values = Array(coords)
|
|
144
|
+
raise ArgumentError, "quantized coordinates must contain x/y pairs" if values.length.odd?
|
|
145
|
+
|
|
146
|
+
values.each do |value|
|
|
147
|
+
unless value.is_a?(Integer) && value.between?(Quantizer::INT64_MIN, Quantizer::INT64_MAX)
|
|
148
|
+
raise RangeError, "quantized coordinate must be a signed int64"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
values.dup
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def each_quantized_edge
|
|
155
|
+
previous_x = @coords[-2]
|
|
156
|
+
previous_y = @coords[-1]
|
|
157
|
+
@coords.each_slice(2) do |x, y|
|
|
158
|
+
yield previous_x, previous_y, x, y
|
|
159
|
+
previous_x = x
|
|
160
|
+
previous_y = y
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def point_on_segment?(px, py, x1, y1, x2, y2)
|
|
165
|
+
cross = ((px - x1) * (y2 - y1)) - ((py - y1) * (x2 - x1))
|
|
166
|
+
return false unless cross.zero?
|
|
167
|
+
|
|
168
|
+
px.between?(*[x1, x2].minmax) && py.between?(*[y1, y2].minmax)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clipper2
|
|
4
|
+
module PathSimplifier
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def call(path, epsilon)
|
|
8
|
+
threshold = Float(epsilon)
|
|
9
|
+
raise ArgumentError, "epsilon must be non-negative and finite" unless threshold.finite? && threshold >= 0
|
|
10
|
+
return path if path.size <= 3
|
|
11
|
+
|
|
12
|
+
quantized_threshold = (threshold * path.scale).round
|
|
13
|
+
points = path.send(:quantized_coords).each_slice(2).to_a
|
|
14
|
+
remove_redundant_points(points, quantized_threshold**2)
|
|
15
|
+
Path.from_coords(points.flatten, scale: path.scale)
|
|
16
|
+
rescue TypeError, ArgumentError => error
|
|
17
|
+
raise error if error.message.start_with?("epsilon")
|
|
18
|
+
|
|
19
|
+
raise ArgumentError, "epsilon must be non-negative and finite"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def remove_redundant_points(points, threshold_squared)
|
|
23
|
+
loop do
|
|
24
|
+
removable = points.each_index.find do |index|
|
|
25
|
+
previous = points[(index - 1) % points.length]
|
|
26
|
+
current = points[index]
|
|
27
|
+
following = points[(index + 1) % points.length]
|
|
28
|
+
point_segment_distance_within?(current, previous, following, threshold_squared)
|
|
29
|
+
end
|
|
30
|
+
break unless removable && points.length > 3
|
|
31
|
+
|
|
32
|
+
points.delete_at(removable)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
private_class_method :remove_redundant_points
|
|
36
|
+
|
|
37
|
+
def point_segment_distance_within?(point, start_point, end_point, threshold_squared)
|
|
38
|
+
segment_x = end_point[0] - start_point[0]
|
|
39
|
+
segment_y = end_point[1] - start_point[1]
|
|
40
|
+
point_x = point[0] - start_point[0]
|
|
41
|
+
point_y = point[1] - start_point[1]
|
|
42
|
+
segment_squared = (segment_x * segment_x) + (segment_y * segment_y)
|
|
43
|
+
return squared_distance(point, start_point) <= threshold_squared if segment_squared.zero?
|
|
44
|
+
|
|
45
|
+
projection = (point_x * segment_x) + (point_y * segment_y)
|
|
46
|
+
return squared_distance(point, start_point) <= threshold_squared if projection <= 0
|
|
47
|
+
return squared_distance(point, end_point) <= threshold_squared if projection >= segment_squared
|
|
48
|
+
|
|
49
|
+
cross = (point_x * segment_y) - (point_y * segment_x)
|
|
50
|
+
(cross * cross) <= threshold_squared * segment_squared
|
|
51
|
+
end
|
|
52
|
+
private_class_method :point_segment_distance_within?
|
|
53
|
+
|
|
54
|
+
def squared_distance(first, second)
|
|
55
|
+
x = first[0] - second[0]
|
|
56
|
+
y = first[1] - second[1]
|
|
57
|
+
(x * x) + (y * y)
|
|
58
|
+
end
|
|
59
|
+
private_class_method :squared_distance
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clipper2
|
|
4
|
+
class Paths
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def [](*paths, scale: nil)
|
|
9
|
+
if paths.length == 1 && paths.first.is_a?(Array) && !Path.point_collection?(paths.first)
|
|
10
|
+
paths = paths.first
|
|
11
|
+
end
|
|
12
|
+
new(paths, scale: scale)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def coerce(value, scale: nil)
|
|
16
|
+
return new([], scale: scale) if value.nil?
|
|
17
|
+
return coerce_paths(value, scale) if value.is_a?(Paths)
|
|
18
|
+
return new([value], scale: scale) if value.is_a?(Path)
|
|
19
|
+
return new([value], scale: scale) if Path.point_collection?(value)
|
|
20
|
+
|
|
21
|
+
new(value, scale: scale)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def from_coords(paths_coords, scale:)
|
|
25
|
+
paths = paths_coords.map { |coords| Path.from_coords(coords, scale: scale) }
|
|
26
|
+
new(paths, scale: scale)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def coerce_paths(value, scale)
|
|
32
|
+
return value if scale.nil? || scale == value.scale
|
|
33
|
+
|
|
34
|
+
raise ScaleError, "cannot use Paths at scale #{value.scale.inspect} with scale #{scale.inspect}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :scale
|
|
39
|
+
|
|
40
|
+
def initialize(paths = [], scale: nil)
|
|
41
|
+
path_specs = normalize_input(paths)
|
|
42
|
+
inferred_scale = infer_scale(path_specs)
|
|
43
|
+
@scale = Quantizer.validate_scale(scale || inferred_scale || Clipper2.default_scale)
|
|
44
|
+
@paths = path_specs.map { |path| coerce_path(path) }.freeze
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def each(&block)
|
|
49
|
+
return enum_for(__method__) unless block
|
|
50
|
+
|
|
51
|
+
@paths.each(&block)
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](index)
|
|
56
|
+
@paths[index]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def size
|
|
60
|
+
@paths.size
|
|
61
|
+
end
|
|
62
|
+
alias length size
|
|
63
|
+
|
|
64
|
+
def empty?
|
|
65
|
+
@paths.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def area
|
|
69
|
+
sum(&:area)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def absolute_area
|
|
73
|
+
sum { |path| path.area.abs }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def bounds
|
|
77
|
+
path_bounds = filter_map(&:bounds)
|
|
78
|
+
return nil if path_bounds.empty?
|
|
79
|
+
|
|
80
|
+
[
|
|
81
|
+
path_bounds.map { |bounds| bounds[0] }.min,
|
|
82
|
+
path_bounds.map { |bounds| bounds[1] }.min,
|
|
83
|
+
path_bounds.map { |bounds| bounds[2] }.max,
|
|
84
|
+
path_bounds.map { |bounds| bounds[3] }.max
|
|
85
|
+
]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def simplify(epsilon)
|
|
89
|
+
self.class.new(map { |path| path.simplify(epsilon) }, scale: scale)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ==(other)
|
|
93
|
+
other.is_a?(self.class) && scale == other.scale && @paths == other.to_a
|
|
94
|
+
end
|
|
95
|
+
alias eql? ==
|
|
96
|
+
|
|
97
|
+
def hash
|
|
98
|
+
[self.class, scale, @paths].hash
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def inspect
|
|
102
|
+
"#<#{self.class.name} scale=#{scale.inspect} paths=#{@paths.inspect}>"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def normalize_input(paths)
|
|
108
|
+
return paths.to_a if paths.is_a?(Paths)
|
|
109
|
+
return [] if paths.respond_to?(:empty?) && paths.empty?
|
|
110
|
+
return [paths] if paths.is_a?(Path) || Path.point_collection?(paths)
|
|
111
|
+
return paths.to_a if paths.respond_to?(:to_a)
|
|
112
|
+
|
|
113
|
+
raise ArgumentError, "paths must be Path objects or point collections"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def infer_scale(paths)
|
|
117
|
+
scales = paths.grep(Path).map(&:scale).uniq
|
|
118
|
+
raise ScaleError, "all paths must use the same scale" if scales.length > 1
|
|
119
|
+
|
|
120
|
+
scales.first
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def coerce_path(path)
|
|
124
|
+
return Path.new(path, scale: scale) unless path.is_a?(Path)
|
|
125
|
+
return path if path.scale == scale
|
|
126
|
+
|
|
127
|
+
raise ScaleError, "cannot use Path at scale #{path.scale.inspect} with scale #{scale.inspect}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clipper2
|
|
4
|
+
module Quantizer
|
|
5
|
+
INT64_MIN = -(2**63)
|
|
6
|
+
INT64_MAX = (2**63) - 1
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def validate_scale(scale)
|
|
11
|
+
value = Float(scale)
|
|
12
|
+
raise ScaleError, "scale must be finite and greater than zero" unless value.finite? && value.positive?
|
|
13
|
+
|
|
14
|
+
scale.is_a?(Integer) ? scale : value
|
|
15
|
+
rescue ArgumentError, TypeError
|
|
16
|
+
raise ScaleError, "scale must be a finite positive number"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def quantize(value, scale)
|
|
20
|
+
numeric = Float(value)
|
|
21
|
+
raise RangeError, "coordinate must be finite" unless numeric.finite?
|
|
22
|
+
|
|
23
|
+
scaled = numeric * scale
|
|
24
|
+
raise RangeError, "coordinate exceeds the int64 range at scale #{scale}" unless scaled.finite?
|
|
25
|
+
|
|
26
|
+
quantized = scaled.round
|
|
27
|
+
unless quantized.between?(INT64_MIN, INT64_MAX)
|
|
28
|
+
raise RangeError, "coordinate exceeds the int64 range at scale #{scale}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
quantized
|
|
32
|
+
rescue ArgumentError, TypeError
|
|
33
|
+
raise RangeError, "coordinate must be a finite number"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dequantize(value, scale)
|
|
37
|
+
value.fdiv(scale)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def quantize_point(point, scale)
|
|
41
|
+
unless point.respond_to?(:[]) && point.respond_to?(:size) && point.size == 2
|
|
42
|
+
raise ArgumentError, "point must contain exactly two coordinates"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
[quantize(point[0], scale), quantize(point[1], scale)]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|