sscharter 0.9.0 → 0.10.1

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.
@@ -0,0 +1,549 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @see Sunniesnow::Tools.path
4
+ module Sunniesnow::Tools::SvgPath
5
+
6
+ Vector2D = Sunniesnow::Utils::Data.define :x, :y do
7
+
8
+ # @!attribute [r] x
9
+ # @return [Float]
10
+ # @!attribute [r] y
11
+ # @return [Float]
12
+
13
+ # @return [Vector2D]
14
+ def +@
15
+ self
16
+ end
17
+
18
+ # @return [Vector2D]
19
+ def -@
20
+ Vector2D.new -x, -y
21
+ end
22
+
23
+ # @param other [Vector2D]
24
+ # @return [Vector2D]
25
+ def + other
26
+ Vector2D.new x + other.x, y + other.y
27
+ end
28
+
29
+ # @param other [Vector2D]
30
+ # @return [Vector2D]
31
+ def - other
32
+ Vector2D.new x - other.x, y - other.y
33
+ end
34
+
35
+ # @param other [Vector2D, Numeric]
36
+ # @return [Vector2D]
37
+ def * other
38
+ other.is_a?(Vector2D) ? Vector2D.new(x*other.x, y*other.y) : Vector2D.new(x*other, y*other)
39
+ end
40
+
41
+ # @param other [Vector2D, Numeric]
42
+ # @return [Vector2D]
43
+ def / other
44
+ other.is_a?(Vector2D) ? Vector2D.new(x/other.x, y/other.y) : Vector2D.new(x/other, y/other)
45
+ end
46
+
47
+ # @return [Array<Float>]
48
+ def to_a
49
+ [x, y]
50
+ end
51
+
52
+ # @return [Float]
53
+ def length
54
+ Math.hypot x, y
55
+ end
56
+
57
+ # @return [Float]
58
+ def angle
59
+ Math.atan2 y, x
60
+ end
61
+
62
+ # @return [Float]
63
+ def length2
64
+ x*x + y*y
65
+ end
66
+
67
+ # @param angle [Numeric] in radians
68
+ # @return [Vector2D]
69
+ def rotate angle
70
+ cos = Math.cos angle
71
+ sin = Math.sin angle
72
+ Vector2D.new x*cos - y*sin, x*sin + y*cos
73
+ end
74
+
75
+ # @param str [String] in the format "x,y".
76
+ # @return [Vector2D]
77
+ def self.from_string str
78
+ new *str.split(',').map(&:to_f)
79
+ end
80
+
81
+ # @param radius [Numeric]
82
+ # @param angle [Numeric] in radians
83
+ # @return [Vector2D]
84
+ def self.from_polar radius, angle
85
+ new Math.cos(angle)*radius, Math.sin(angle)*radius
86
+ end
87
+ end
88
+
89
+ class GeometryError < StandardError
90
+ end
91
+
92
+ class PathSegment
93
+
94
+ # @return [Vector2D]
95
+ def at t
96
+ raise NotImplementedError
97
+ end
98
+
99
+ # @param s [Vector2D]
100
+ def at_length s
101
+ raise NotImplementedError
102
+ end
103
+
104
+ # @return [Vector2D]
105
+ def begin
106
+ raise NotImplementedError
107
+ end
108
+
109
+ # @return [Vector2D]
110
+ def end
111
+ raise NotImplementedError
112
+ end
113
+
114
+ # @return [Float]
115
+ def length
116
+ raise NotImplementedError
117
+ end
118
+ end
119
+
120
+ class Line < PathSegment
121
+
122
+ # @return [Vector2D]
123
+ attr_reader :begin
124
+
125
+ # @return [Vector2D]
126
+ attr_reader :end
127
+
128
+ # @return [Float]
129
+ attr_reader :length
130
+
131
+ # @param begin_point [Vector2D]
132
+ # @param end_point [Vector2D]
133
+ def initialize begin_point, end_point
134
+ @begin = begin_point
135
+ @end = end_point
136
+ @length = (@end - @begin).length
137
+ end
138
+
139
+ # @param t [Float]
140
+ # @return [Vector2D]
141
+ def at t
142
+ @begin*(1-t) + @end*t
143
+ end
144
+
145
+ # @param s [Float]
146
+ # @return [Vector2D]
147
+ def at_length s
148
+ at s/@length
149
+ end
150
+ end
151
+
152
+ module Curve
153
+
154
+ # @return [Float]
155
+ def length
156
+ @lengths.last
157
+ end
158
+
159
+ # @param s [Float]
160
+ # @return [Vector2D]
161
+ def at_length s
162
+ k = (@lengths.bsearch_index { _1 > s } || @lengths.length - 1) - 1
163
+ @segments[k].at_length s - @lengths[k]
164
+ end
165
+
166
+ # @param n [Integer]
167
+ # @return [void]
168
+ private def make_segments n
169
+ @segments = Array.new(n) { Line.new at(_1/n.to_f), at((_1+1)/n.to_f) }
170
+ @lengths = @segments.each_with_object([0.0]) { _2.push _2.last + _1.length }
171
+ nil
172
+ end
173
+ end
174
+
175
+ class QuadraticBezier < PathSegment
176
+ include Curve
177
+
178
+ # @return [Vector2D]
179
+ attr_reader :begin
180
+
181
+ # @return [Vector2D]
182
+ attr_reader :control
183
+
184
+ # @return [Vector2D]
185
+ attr_reader :end
186
+
187
+ # @param begin_point [Vector2D]
188
+ # @param control_point [Vector2D]
189
+ # @param end_point [Vector2D]
190
+ # @param segments [Integer]
191
+ def initialize begin_point, control_point, end_point, segments: 8
192
+ @begin = begin_point
193
+ @control = control_point
194
+ @end = end_point
195
+ make_segments segments
196
+ end
197
+
198
+ # @param t [Float]
199
+ # @return [Vector2D]
200
+ def at t
201
+ @begin*(1-t)**2 + @control*(2*(1-t)*t) + @end*t**2
202
+ end
203
+ end
204
+
205
+ class CubicBezier < PathSegment
206
+ include Curve
207
+
208
+ # @return [Vector2D]
209
+ attr_reader :begin
210
+
211
+ # @return [Vector2D]
212
+ attr_reader :control1
213
+
214
+ # @return [Vector2D]
215
+ attr_reader :control2
216
+
217
+ # @return [Vector2D]
218
+ attr_reader :end
219
+
220
+ # @param begin_point [Vector2D]
221
+ # @param control_point1 [Vector2D]
222
+ # @param control_point2 [Vector2D]
223
+ # @param end_point [Vector2D]
224
+ # @param segments [Integer]
225
+ def initialize begin_point, control_point1, control_point2, end_point, segments: 16
226
+ @begin = begin_point
227
+ @control1 = control_point1
228
+ @control2 = control_point2
229
+ @end = end_point
230
+ make_segments segments
231
+ end
232
+
233
+ # @param t [Float]
234
+ # @return [Vector2D]
235
+ def at t
236
+ @begin*(1-t)**3 + @control1*(3*(1-t)**2*t) + @control2*(3*(1-t)*t**2) + @end*t**3
237
+ end
238
+ end
239
+
240
+ class Arc < PathSegment
241
+ include Curve
242
+
243
+ # @return [Vector2D]
244
+ attr_reader :begin
245
+
246
+ # @return [Numeric]
247
+ attr_reader :radius
248
+
249
+ # @return [Numeric]
250
+ attr_reader :rotation
251
+
252
+ # @return [Boolean]
253
+ attr_reader :large_arc
254
+
255
+ # @return [Boolean]
256
+ attr_reader :sweep_positive
257
+
258
+ # @return [Vector2D]
259
+ attr_reader :end
260
+
261
+ # @param begin_point [Vector2D]
262
+ # @param radius [Vector2D]
263
+ # @param rotation [Numeric]
264
+ # @param large_arc [Boolean]
265
+ # @param sweep_positive [Boolean]
266
+ # @param end_point [Vector2D]
267
+ # @param segments [Integer]
268
+ def initialize begin_point, radius, rotation, large_arc, sweep_positive, end_point, segments: 8
269
+ @begin = begin_point
270
+ @radius = radius
271
+ @rotation = rotation
272
+ @large_arc = large_arc
273
+ @sweep_positive = sweep_positive
274
+ @end = end_point
275
+ raise GeometryError.new 'Radius must be positive' if @radius.x <= 0 || @radius.y <= 0
276
+ raise GeometryError.new 'Cannot draw arc with same begin and end point' if @begin == @end
277
+ solve_center
278
+ make_segments segments
279
+ end
280
+
281
+ # @param t [Float]
282
+ # @return [Vector2D]
283
+ def at t
284
+ @center + (Vector2D.from_polar(1, @angle1*(1-t) + @angle2*t) * @radius).rotate(@rotation)
285
+ end
286
+
287
+ # @return [Vector2D]
288
+ private def solve_center
289
+ p1 = @begin.rotate(-@rotation) / @radius
290
+ p2 = @end.rotate(-@rotation) / @radius
291
+ diff = p2 - p1
292
+ if (distance2 = diff.length2) > 4
293
+ scale = Math.sqrt(distance2)/2
294
+ distance2 = 4
295
+ @radius *= scale
296
+ diff /= scale
297
+ end
298
+ normal = Vector2D.new -diff.y, diff.x
299
+ sign = @large_arc == @sweep_positive ? -1 : 1
300
+ center = (p1 + p2)/2 + normal * (sign * Math.sqrt(1.0/distance2 - 0.25))
301
+ @angle1 = (p1 - center).angle
302
+ @angle2 = (p2 - center).angle
303
+ if @angle2 > @angle1 != @sweep_positive
304
+ @angle2 += (@sweep_positive ? 1 : -1) * 2*Math::PI
305
+ end
306
+ @center = (center * @radius).rotate @rotation
307
+ end
308
+ end
309
+
310
+ class CommandError < StandardError
311
+ end
312
+
313
+ class Command
314
+
315
+ # @param name [Symbol]
316
+ # @param parameters [Array<Symbol>]
317
+ # @param follow_up [Symbol]
318
+ def initialize name, parameters, follow_up = name
319
+ @name = name
320
+ @parameters = parameters
321
+ @follow_up = follow_up
322
+ end
323
+
324
+ # @param path [Path]
325
+ # @param tokens [Array<String>]
326
+ # @return [void]
327
+ def parse path, tokens
328
+ relative = (?a..?z).include? tokens.shift
329
+ name = @name.to_sym
330
+ begin
331
+ arguments = @parameters.map do |parameter|
332
+ case parameter
333
+ when :point
334
+ point = Vector2D.new tokens.shift.to_f, tokens.shift.to_f
335
+ point += path.current if relative
336
+ point
337
+ when :point_x
338
+ x = tokens.shift.to_f
339
+ Vector2D.new relative ? path.current.x + x : x, path.current.y
340
+ when :point_y
341
+ y = tokens.shift.to_f
342
+ Vector2D.new path.current.x, relative ? path.current.y + y : y
343
+ when :vector
344
+ Vector2D.new tokens.shift.to_f, tokens.shift.to_f
345
+ when :number
346
+ tokens.shift.to_f
347
+ when :boolean
348
+ raise "Invalid boolean: #{tokens.first}" unless %w[0 1].include? tokens.first
349
+ tokens.shift == ?1
350
+ end
351
+ end
352
+ path.public_send name, *arguments
353
+ name = @follow_up.to_sym
354
+ end until tokens.empty? || tokens.first =~ /[a-zA-Z]/
355
+ end
356
+ end
357
+
358
+ class Path
359
+ COMMANDS = {
360
+ m: Command.new(:move_to, %i[point], :line_to),
361
+ l: Command.new(:line_to, %i[point]),
362
+ h: Command.new(:line_to, %i[point_x]),
363
+ v: Command.new(:line_to, %i[point_y]),
364
+ z: Command.new(:close, %i[], :unexpected_token),
365
+ c: Command.new(:cubic, %i[point point point]),
366
+ s: Command.new(:smooth_cubic, %i[point point]),
367
+ q: Command.new(:quadratic, %i[point point]),
368
+ t: Command.new(:smooth_quadratic, %i[point]),
369
+ a: Command.new(:arc, %i[vector number boolean boolean point]),
370
+ }
371
+
372
+ # @return [Vector2D]
373
+ attr_reader :current
374
+
375
+ # @param data [String]
376
+ def initialize data
377
+ @segments = []
378
+ @lengths = [0.0]
379
+ @last_initial = 0
380
+ @current = Vector2D.new 0.0, 0.0
381
+ data = data.gsub /[a-df-zA-DF-Z]/, ' \0 ' # exclude E for scientific notation
382
+ data.gsub! /[,\s]+/, ' '
383
+ data.strip!
384
+ parse_instructions data.split
385
+ end
386
+
387
+ # @raise [CommandError]
388
+ # @return [void]
389
+ def unexpected_token
390
+ raise CommandError.new 'Unexpected token'
391
+ end
392
+
393
+ # @param point [Vector2D]
394
+ # @return [Vector2D]
395
+ def move_to point
396
+ @last_initial = @segments.length
397
+ @current = point
398
+ end
399
+
400
+ # @param point [Vector2D]
401
+ # @return [Vector2D]
402
+ def line_to point
403
+ add_segment Line.new @current, point
404
+ end
405
+
406
+ # @return [Vector2D]
407
+ def close
408
+ point = @segments[@last_initial].begin
409
+ @current == point ? point : line_to(point)
410
+ end
411
+
412
+ # @param control1 [Vector2D]
413
+ # @param control2 [Vector2D]
414
+ # @param point [Vector2D]
415
+ # @return [Vector2D]
416
+ def cubic control1, control2, point
417
+ add_segment CubicBezier.new @current, control1, control2, point
418
+ end
419
+
420
+ # @param control2 [Vector2D]
421
+ # @param point [Vector2D]
422
+ # @return [Vector2D]
423
+ def smooth_cubic control2, point
424
+ last = @segments.last
425
+ control1 = last.is_a?(CubicBezier) ? @current*2 - last.control2 : @current
426
+ cubic control1, control2, point
427
+ end
428
+
429
+ # @param control [Vector2D]
430
+ # @param point [Vector2D]
431
+ # @return [Vector2D]
432
+ def quadratic control, point
433
+ add_segment QuadraticBezier.new @current, control, point
434
+ end
435
+
436
+ # @param point [Vector2D]
437
+ # @return [Vector2D]
438
+ def smooth_quadratic point
439
+ last = @segments.last
440
+ control = last.is_a?(QuadraticBezier) ? @current*2 - last.control : @current
441
+ quadratic control, point
442
+ end
443
+
444
+ # @param radius [Numeric]
445
+ # @param rotation [Numeric]
446
+ # @param large_arc [Boolean]
447
+ # @param sweep_positive [Boolean]
448
+ # @param point [Vector2D]
449
+ # @return [Vector2D]
450
+ def arc radius, rotation, large_arc, sweep_positive, point
451
+ add_segment Arc.new @current, radius, rotation*Math::PI/180, large_arc, sweep_positive, point
452
+ end
453
+
454
+ # @param segment [PathSegment]
455
+ # @return [Vector2D]
456
+ def add_segment segment
457
+ @segments.push segment
458
+ @lengths.push @lengths.last + segment.length
459
+ @current = segment.end
460
+ end
461
+
462
+ # @return [Float]
463
+ def length
464
+ @lengths.last
465
+ end
466
+
467
+ # @param s [Float]
468
+ # @return [Vector2D]
469
+ def at_length s
470
+ len = length
471
+ s %= len*2
472
+ s = 2*len - s if s > len
473
+ i = @lengths.bsearch_index { _1 > s } - 1
474
+ @segments[i].at_length s - @lengths[i]
475
+ end
476
+
477
+ # @param range [Range, Integer]
478
+ # @param total [Integer]
479
+ # @yieldparam x [Float]
480
+ # @yieldparam y [Float]
481
+ # @yieldparam i [Integer]
482
+ # @return [Path] +self+.
483
+ def samples range, total = range.is_a?(Range) ? range.end : range
484
+ return to_enum __method__, range, total unless block_given?
485
+ range = 0...range unless range.is_a? Range
486
+ len = length
487
+ delta = len / total.to_f
488
+ s = range.begin * delta
489
+ sign = 1
490
+ s %= len*2
491
+ if s > len
492
+ s = 2*len - s
493
+ sign *= -1
494
+ end
495
+ i = @lengths.bsearch_index { _1 > s } - 1
496
+ range.each do |j|
497
+ if j == range.begin
498
+ yield *@segments[i].at_length(s - @lengths[i]).to_a, j
499
+ next
500
+ end
501
+ s += delta*sign
502
+ s %= len*2
503
+ if s > len
504
+ s = 2*len - s
505
+ sign *= -1
506
+ end
507
+ i -= 1 while i > 0 && s < @lengths[i]
508
+ i += 1 while i < @lengths.length - 1 && s > @lengths[i+1]
509
+ yield *@segments[i].at_length(s - @lengths[i]).to_a, j
510
+ end
511
+ self
512
+ end
513
+
514
+ # @param tokens [Array<String>]
515
+ # @return [void]
516
+ private def parse_instructions tokens
517
+ while instruction = tokens.first
518
+ command = COMMANDS[instruction.downcase.to_sym]
519
+ raise CommandError.new "Unknown command #{instruction}" unless command
520
+ command.parse self, tokens
521
+ end
522
+ end
523
+ end
524
+ end
525
+
526
+ module Sunniesnow::Tools
527
+
528
+ # Turn any SVG path into uniformly sampled points.
529
+ # @param data [String] The SVG path definition.
530
+ # @return [SvgPath::Path]
531
+ # @yieldparam x [Float]
532
+ # @yieldparam y [Float]
533
+ # @yieldparam i [Integer]
534
+ # @overload path data, range, total = range.end, &block
535
+ # @param range [Range]
536
+ # @param total [Integer]
537
+ # @overload path data, range_end, total = range_end, &block
538
+ # @param range_end [Integer]
539
+ # @param total [Integer]
540
+ # @example
541
+ # path 'M -6,-0 C -4,2 -0,-0.7 -0,2 -0,4.7 6,-1 5,1 4,3 1,2 -0,-0 -1,-2 10,-2 6,-3 2,-4 -6,-0 -4,2 c 2,2 3,-8 0,-5', 128 do |x, y, i|
542
+ # d x, y; b 1/32r
543
+ # end
544
+ module_function def path data, *args, &block
545
+ result = SvgPath::Path.new data
546
+ result.samples *args, &block if block
547
+ result
548
+ end
549
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunniesnow::Tools
4
+ end
5
+
6
+ require_relative 'tools/svg_path'
@@ -1,8 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @note Internal API.
1
4
  module Sunniesnow::Utils
2
5
 
3
6
  refine Array do
4
- # If there no match, return the next index.
5
- # Less than all: return 0. Greater than all: return array.length.
7
+ # Do a binary search. If there is a match, return the index of the match.
8
+ # Otherwise, if +right+, return the index of the last element that is less than the value;
9
+ # otherwise, return the index of the first element that is greater than the value.
6
10
  def bisect value = nil, right: false, &compare_function
7
11
  if value && compare_function
8
12
  raise ArgumentError, "Cannot specify both value and compare_function"
@@ -38,4 +42,17 @@ module Sunniesnow::Utils
38
42
  to_s.snake_to_camel.to_sym
39
43
  end
40
44
  end
45
+
46
+ if Object.const_defined? :Data # Ruby 3.2
47
+ Data = Data
48
+ else
49
+ class Data < Struct
50
+ def self.define *fields, &block
51
+ new *fields do
52
+ fields.each { undef_method "#{_1}=" }
53
+ class_eval &block if block
54
+ end
55
+ end
56
+ end
57
+ end
41
58
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sunniesnow
4
4
  class Charter
5
- VERSION = "0.9.0"
5
+ VERSION = "0.10.1"
6
6
  end
7
7
  end