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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +165 -0
  4. data/examples/larb_transform.rb +17 -0
  5. data/examples/rugl_mesh.rb +33 -0
  6. data/examples/svg_boolean.rb +28 -0
  7. data/ext/clipper2/CMakeLists.txt +27 -0
  8. data/ext/clipper2/clipper2_native.cpp +2 -0
  9. data/ext/clipper2/extconf.rb +27 -0
  10. data/ext/clipper2/generate_bindings.rb +73 -0
  11. data/ext/clipper2/memory_check.cpp +48 -0
  12. data/ext/clipper2/vendor/LICENSE +23 -0
  13. data/ext/clipper2/vendor/UPSTREAM.md +11 -0
  14. data/ext/clipper2/vendor/include/clipper2/clipper.core.h +1159 -0
  15. data/ext/clipper2/vendor/include/clipper2/clipper.engine.h +635 -0
  16. data/ext/clipper2/vendor/include/clipper2/clipper.export.h +851 -0
  17. data/ext/clipper2/vendor/include/clipper2/clipper.h +796 -0
  18. data/ext/clipper2/vendor/include/clipper2/clipper.minkowski.h +117 -0
  19. data/ext/clipper2/vendor/include/clipper2/clipper.offset.h +125 -0
  20. data/ext/clipper2/vendor/include/clipper2/clipper.rectclip.h +80 -0
  21. data/ext/clipper2/vendor/include/clipper2/clipper.triangulation.h +27 -0
  22. data/ext/clipper2/vendor/include/clipper2/clipper.version.h +6 -0
  23. data/ext/clipper2/vendor/src/clipper.engine.cpp +3152 -0
  24. data/ext/clipper2/vendor/src/clipper.offset.cpp +661 -0
  25. data/ext/clipper2/vendor/src/clipper.rectclip.cpp +1027 -0
  26. data/ext/clipper2/vendor/src/clipper.triangulation.cpp +1221 -0
  27. data/lib/clipper2/api.rb +45 -0
  28. data/lib/clipper2/c_paths64.rb +80 -0
  29. data/lib/clipper2/earcut/engine.rb +191 -0
  30. data/lib/clipper2/earcut/geometry.rb +100 -0
  31. data/lib/clipper2/earcut/node.rb +18 -0
  32. data/lib/clipper2/earcut/ring.rb +94 -0
  33. data/lib/clipper2/earcut.rb +18 -0
  34. data/lib/clipper2/errors.rb +19 -0
  35. data/lib/clipper2/generated.rb +62 -0
  36. data/lib/clipper2/native.rb +38 -0
  37. data/lib/clipper2/native_memory.rb +64 -0
  38. data/lib/clipper2/operations.rb +206 -0
  39. data/lib/clipper2/path.rb +171 -0
  40. data/lib/clipper2/path_simplifier.rb +61 -0
  41. data/lib/clipper2/paths.rb +130 -0
  42. data/lib/clipper2/quantizer.rb +48 -0
  43. data/lib/clipper2/triangulation.rb +103 -0
  44. data/lib/clipper2/version.rb +5 -0
  45. data/lib/clipper2.rb +34 -0
  46. data/licenses/CLIPPER2-BSL-1.0.txt +23 -0
  47. data/licenses/EARCUT-ISC.txt +15 -0
  48. 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