asobi 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.
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # A picture in motion: a function from time (in seconds) to a picture.
5
+ #
6
+ # wobble = Asobi.motion(2) { |seconds| circle.move(right: Math.sin(seconds * Math::PI) * 10) }
7
+ # wobble.at(0.5) the picture half a second in
8
+ # wobble.every 2.0, the seconds until it is back where it started
9
+ # wobble.frames(fps: 30) one loop of pictures, lazily
10
+ #
11
+ # There is no frame counter and no state: the same time always gives the
12
+ # same picture, so playing can start (or restart) anywhere.
13
+ #
14
+ # A Motion understands every word a picture does (move, colored, repeat,
15
+ # in_ring, on, beside ...), applied at every moment, and every motion word
16
+ # (spin, pulse ...) too:
17
+ #
18
+ # flower.spin(every: 8).pulse(every: 1).colored(:crimson).repeat(6).in_ring
19
+ #
20
+ # Pictures and motions mix freely: ring.on(dot.pulse) and [dot, dot.spin]
21
+ # are motions as well.
22
+ class Motion
23
+ include Art
24
+
25
+ # Combined loops longer than this give way to their longest part.
26
+ LONGEST = 60
27
+ # How close two periods may be and still count as the same (seconds).
28
+ TOLERANCE = Rational(1, 10_000)
29
+
30
+ # Asobi::Motion.new(2) { |seconds| picture }
31
+ def initialize(every = 1.0, &block)
32
+ unless block
33
+ raise Messages.error(ArgumentError, :need_block, word: :motion,
34
+ example: "Asobi.motion(2) { |seconds| circle.rotate(seconds * 180) }")
35
+ end
36
+
37
+ setup(Motion.seconds(every, :motion, :every, "Asobi.motion(2) { |seconds| ... }"), block, nil)
38
+ end
39
+
40
+ # Seconds until the motion is back where it started (a positive Float).
41
+ attr_reader :every
42
+
43
+ # The picture at +seconds+ (any number; it repeats every +every+ seconds).
44
+ def at(seconds = 0)
45
+ Check.number(seconds, word: :at, name: :seconds, example: "motion.at(0.5)")
46
+ Motion.quietly(@seed) { Motion.node_at(@function.call(seconds.to_f), seconds.to_f) }
47
+ end
48
+
49
+ # One loop of pictures, evenly spaced over +every+ seconds, computed only
50
+ # when asked for. There are every * fps of them (at least one).
51
+ def frames(fps: 30)
52
+ Motion.seconds(fps, :frames, :fps, "frames(fps: 30)")
53
+ count = [(every * fps).round, 1].max
54
+ (0...count).lazy.map { |i| at(every * i / count) }
55
+ end
56
+
57
+ def to_art = self
58
+
59
+ def inspect = "#<Asobi::Motion every #{Asobi.fmt(every)}s>"
60
+ alias to_s inspect
61
+
62
+ # The room the motion needs: the box around its pictures over one loop.
63
+ def bounds
64
+ n = [[(every * 30).round, 1].max, 240].min
65
+ Box.union(Array.new(n) { |i| at(every * i / n).box(IDENTITY) }) || Box.new(0.0, 0.0, 0.0, 0.0)
66
+ end
67
+
68
+ # A motion is not one picture: these answer a teaching error.
69
+ def box(_matrix = IDENTITY) = raise(Motion.not_a_picture(self))
70
+ def svg = raise(Motion.not_a_picture(self))
71
+ def to_svg(**) = raise(Motion.not_a_picture(self))
72
+
73
+ # A page that plays by itself: flower.spin.save("flower.html")
74
+ def save(path, **options)
75
+ unless File.extname(path.to_s).downcase == ".html"
76
+ raise Messages.error(ArgumentError, :bad_motion_save, path: path.to_s.inspect)
77
+ end
78
+
79
+ reel = Motion.reel
80
+ raise Messages.error(NameError, :no_reel, path: path.to_s.inspect) unless reel
81
+
82
+ reel.save(self, path, **options)
83
+ end
84
+
85
+ # --- the words of Asobi::Many, at every moment -------------------------
86
+
87
+ %i[in_row in_column in_grid in_ring in_fan in_spiral scattered change_each].each do |word|
88
+ define_method(word) do |*args, **options, &block|
89
+ changed = Motion.combine([self]) { |(node)| Many.of(node).public_send(word, *args, **options, &block) }
90
+ changed.at(0) # say what is wrong now, not when playing
91
+ changed
92
+ end
93
+ end
94
+
95
+ # Each one, in turn, on its own. The last motion word is done by each
96
+ # picture of the group separately, around that picture's own centre
97
+ # (the middle of its box before the word moves it), and each starts
98
+ # +gap+ seconds after the one before. By default the loop is shared out
99
+ # evenly, which makes a wave.
100
+ #
101
+ # So with one_by_one, pulse grows each dot in place, spin turns each one
102
+ # where it stands, sway rocks each one. Words that only move or colour
103
+ # (bounce, drift, orbit, fade, shift_color) look the same either way,
104
+ # only staggered. Without one_by_one the word acts on the whole group
105
+ # around the origin: ring.pulse breathes, ring.spin turns the ring.
106
+ # Your own animate block also gets each picture as if it sat alone at
107
+ # the origin, and its answer is put back in place.
108
+ #
109
+ # On a single picture one_by_one changes nothing.
110
+ #
111
+ # dot.repeat(12).in_ring.pulse(every: 1).one_by_one
112
+ def one_by_one(gap_ = nil, gap: gap_)
113
+ Check.number(gap, word: :one_by_one, name: :gap, example: "one_by_one(gap: 0.1)", min: 0) unless gap.nil?
114
+ raise Messages.error(ArgumentError, :no_motion_word) unless @stagger
115
+
116
+ @stagger.call(gap.nil? ? :even : gap.to_f)
117
+ end
118
+
119
+ # Earlier moments drawn under this one: the picture +gap+, 2 * +gap+ ...
120
+ # seconds ago, each fainter than the last. +gap+ is a short moment
121
+ # (TRAIL_GAP seconds) unless given.
122
+ # flower.spin.trail, dot.orbit.trail(count: 8, gap: 0.03)
123
+ def trail(count_ = nil, count: count_ || 5, gap: nil)
124
+ n = Check.whole(count, word: :trail, name: :count, example: "trail(count: 5, gap: 0.05)", min: 1)
125
+ gap = gap.nil? ? TRAIL_GAP : Check.number(gap, word: :trail, name: :gap, example: "trail(count: 5, gap: 0.05)", min: 0)
126
+ source = self
127
+ stagger = @stagger && ->(g) { source.stagger_by(g).trail(count: n, gap:) }
128
+ Motion.build(every, stagger:) do |t|
129
+ layers = n.downto(1).map do |k|
130
+ fade = (TRAIL_FIRST - (TRAIL_FIRST - TRAIL_FAINTEST) * (k - 1) / [n - 1, 1].max).round(6)
131
+ Asobi.restyle(source.at(t - k * gap)) { |s| s.merge(opacity: ((s[:opacity] || 1.0) * fade).round(6)) }
132
+ end
133
+ Many.new(layers + [source.at(t)])
134
+ end
135
+ end
136
+
137
+ TRAIL_GAP = 0.05
138
+ TRAIL_FIRST = 0.5 # the most recent copy
139
+ TRAIL_FAINTEST = 0.1 # the oldest copy
140
+
141
+ # --- building ----------------------------------------------------------
142
+
143
+ class << self
144
+ # A Motion without the checks, for asobi's own words. +stagger+ is a
145
+ # proc(gap) that rebuilds this motion with its last word staggered.
146
+ def build(every, stagger: nil, &function)
147
+ allocate.tap { _1.send(:setup, every.to_f, function, stagger) }
148
+ end
149
+
150
+ # Like Asobi.art, but a Motion (or anything that becomes one) is
151
+ # answered as it is instead of being refused.
152
+ def reach(object, as: nil)
153
+ return object if object.is_a?(Motion)
154
+ return combine([as]) { |(shape)| reach(object, as: shape) } if as.is_a?(Motion)
155
+
156
+ original = object
157
+ 16.times do
158
+ return object if object.is_a?(Node) || object.is_a?(Motion)
159
+
160
+ built_in = Convert.call(object, as)
161
+ return built_in if built_in
162
+ unless object.respond_to?(:to_art)
163
+ raise Messages.error(TypeError, :not_art, thing: Asobi.send(:describe, original))
164
+ end
165
+
166
+ object = object.to_art
167
+ end
168
+ raise Messages.error(TypeError, :to_art_loop, thing: Asobi.send(:describe, original))
169
+ end
170
+
171
+ # The picture +thing+ shows at +seconds+.
172
+ def node_at(thing, seconds)
173
+ found = reach(thing)
174
+ found.is_a?(Motion) ? found.at(seconds) : found
175
+ end
176
+
177
+ # Several parts (pictures or motions) made into one with +block+,
178
+ # which gets the parts' pictures at each moment. A motion if any part
179
+ # is one.
180
+ def combine(parts, &block)
181
+ motions = parts.grep(Motion).uniq(&:__id__)
182
+ return block.call(parts) if motions.empty?
183
+
184
+ staggerable = motions.select(&:staggerable?)
185
+ stagger =
186
+ if staggerable.size == 1
187
+ chosen = staggerable.first
188
+ lambda do |gap|
189
+ staggered = chosen.stagger_by(gap)
190
+ combine(parts.map { _1.equal?(chosen) ? staggered : _1 }, &block)
191
+ end
192
+ end
193
+ build(lcm_period(motions.map(&:every)), stagger:) do |t|
194
+ block.call(parts.map { _1.is_a?(Motion) ? _1.at(t) : _1 })
195
+ end
196
+ end
197
+
198
+ # A motion word: +effect+ gets a picture and the phase (0.0 up to 1.0)
199
+ # and answers that picture at that point of the loop.
200
+ def word(receiver, period, &effect)
201
+ source = reach(receiver)
202
+ period = period.to_f
203
+ every = source.is_a?(Motion) ? lcm_period([source.every, period]) : period
204
+ make = lambda do |gap|
205
+ build(every, stagger: gap ? nil : make) do |t|
206
+ node = source.is_a?(Motion) ? source.at(t) : source
207
+ if gap && node.is_a?(Many) && node.size > 1
208
+ step = gap == :even ? period / node.size : gap
209
+ items = node.items.each_with_index.map do |item, i|
210
+ on_its_own(item, t) { |alone| effect.call(alone, phase(t - i * step, period)) }
211
+ end
212
+ Many.new(items, node.style)
213
+ else
214
+ node_at(effect.call(node, phase(t, period)), t)
215
+ end
216
+ end
217
+ end
218
+ make.call(nil)
219
+ end
220
+
221
+ # +item+ moved so the centre of its box is at the origin, changed by
222
+ # the block, then moved back: the change happens around its own centre.
223
+ def on_its_own(item, t)
224
+ box = item.box(IDENTITY)
225
+ return node_at(yield(item), t) unless box
226
+
227
+ x = box.cx
228
+ y = box.cy
229
+ alone = Asobi.transform(item, Affine.translate(-x, -y))
230
+ Asobi.transform(node_at(yield(alone), t), Affine.translate(x, y))
231
+ end
232
+
233
+ # Where in its loop a motion of +period+ is at time +t+: 0.0 up to 1.0.
234
+ def phase(t, period)
235
+ q = t / period
236
+ f = q - q.floor
237
+ f < 1e-9 || f > 1 - 1e-9 ? 0.0 : f
238
+ end
239
+
240
+ # The least common multiple of the periods, worked out in fractions;
241
+ # the longest one when that would be more than a minute.
242
+ def lcm_period(periods)
243
+ fractions = periods.map { _1.to_r.rationalize(TOLERANCE) }
244
+ lcm = fractions.reduce do |a, b|
245
+ Rational(a.numerator.lcm(b.numerator), a.denominator.gcd(b.denominator))
246
+ end
247
+ lcm > LONGEST ? periods.max.to_f : lcm.to_f
248
+ end
249
+
250
+ # A number of seconds (more than 0), or a teaching error.
251
+ def seconds(value, word, name, example)
252
+ Check.number(value, word:, name:, example:, min: 0)
253
+ unless value.positive?
254
+ raise Messages.error(ArgumentError, :need_positive, word:, name:, example:, got: value.inspect)
255
+ end
256
+
257
+ value.to_f
258
+ end
259
+
260
+ # The size of a picture or motion, as a Box (nil when empty).
261
+ def extent(thing)
262
+ found = reach(thing)
263
+ found.is_a?(Motion) ? found.bounds : found.box(IDENTITY)
264
+ end
265
+
266
+ def not_a_picture(motion) = Messages.error(TypeError, :motion_not_picture, thing: motion.inspect)
267
+
268
+ # The Reel writer, when it is loaded.
269
+ def reel
270
+ defined?(Asobi::Reel) && Asobi::Reel.respond_to?(:save) ? Asobi::Reel : nil
271
+ end
272
+
273
+ # Run +block+ with seedless randomness drawn from +seed+, starting
274
+ # from the first call each time, and put things back afterwards. So a
275
+ # motion that scatters at every moment scatters the same way at every
276
+ # moment, and sampling it never disturbs anything else.
277
+ def quietly(seed)
278
+ saved_seed = Asobi::Random.session_seed
279
+ saved_calls = Asobi::Random.calls
280
+ Asobi::Random.session_seed = seed
281
+ yield
282
+ ensure
283
+ Asobi::Random.session_seed = saved_seed
284
+ Asobi::Random.instance_variable_set(:@calls, saved_calls)
285
+ end
286
+ end
287
+
288
+ def staggerable? = !@stagger.nil?
289
+
290
+ # This motion with its last word staggered (see one_by_one).
291
+ def stagger_by(gap) = @stagger.call(gap)
292
+
293
+ private
294
+
295
+ def setup(every, function, stagger)
296
+ @every = every
297
+ @function = function
298
+ @stagger = stagger
299
+ # Each motion takes one seed from the run's random sequence when it is
300
+ # made, so it is the same in every evaluation of the same sketch.
301
+ @seed = ::Random.new(Asobi::Random.next_seed).rand(1 << 64)
302
+ end
303
+ end
304
+
305
+ # A picture in motion from a block that gets the time in seconds.
306
+ # Asobi.motion(2) { |seconds| circle.rotate(seconds * 180) }
307
+ def self.motion(every = 1.0, &) = Motion.new(every, &)
308
+
309
+ # --- the motion words, on every picture (and every motion) ---------------
310
+ module Art
311
+ # Turn round and round, once every +every+ seconds.
312
+ def spin(every_ = nil, every: every_ || 4, clockwise: true)
313
+ period = Motion.seconds(every, :spin, :every, "spin(every: 4)")
314
+ sign = clockwise ? 1 : -1
315
+ Motion.word(self, period) { |art, p| Asobi.transform(art, Affine.rotate(sign * 360.0 * p)) }
316
+ end
317
+
318
+ # Grow to +to+ times the size and back.
319
+ def pulse(every_ = nil, every: every_ || 1, to: 1.2)
320
+ period = Motion.seconds(every, :pulse, :every, "pulse(every: 1, to: 1.2)")
321
+ Check.number(to, word: :pulse, name: :to, example: "pulse(every: 1, to: 1.2)", min: 0)
322
+ Motion.word(self, period) { |art, p| Asobi.transform(art, Affine.scale(1 + (to - 1) * Asobi.swell(p))) }
323
+ end
324
+
325
+ # Rock +by+ degrees one way, then the other.
326
+ def sway(every_ = nil, every: every_ || 2, by: 10)
327
+ period = Motion.seconds(every, :sway, :every, "sway(every: 2, by: 10)")
328
+ Check.number(by, word: :sway, name: :by, example: "sway(every: 2, by: 10)")
329
+ Motion.word(self, period) { |art, p| Asobi.transform(art, Affine.rotate(by * Math.sin(2 * Math::PI * p))) }
330
+ end
331
+
332
+ # Go round a circle of +radius+ (clockwise from the top). Without
333
+ # radius:, twice the picture's size (its larger side).
334
+ def orbit(radius_ = nil, radius: radius_, every: 4)
335
+ period = Motion.seconds(every, :orbit, :every, "orbit(radius: 40, every: 4)")
336
+ if radius.nil?
337
+ box = Motion.extent(self)
338
+ radius = box ? [box.width, box.height].max : 20.0
339
+ end
340
+ Check.number(radius, word: :orbit, name: :radius, example: "orbit(radius: 40, every: 4)", min: 0)
341
+ Motion.word(self, period) do |art, p|
342
+ x, y = Geometry.polar(radius, 360.0 * p)
343
+ Asobi.transform(art, Affine.translate(x, y))
344
+ end
345
+ end
346
+
347
+ # Fade to +to+ (0 is invisible, 1 is solid) and back.
348
+ def fade(every_ = nil, every: every_ || 2, to: 0.2)
349
+ period = Motion.seconds(every, :fade, :every, "fade(every: 2, to: 0.2)")
350
+ Check.number(to, word: :fade, name: :to, example: "fade(every: 2, to: 0.2)", min: 0, max: 1)
351
+ Motion.word(self, period) do |art, p|
352
+ amount = (1 - (1 - to) * Asobi.swell(p)).round(9) # so fade(to: 0.2) reaches exactly 0.2
353
+ next art if amount == 1
354
+
355
+ Asobi.restyle(art) { |s| s.merge(opacity: (s[:opacity] || 1.0) * amount) }
356
+ end
357
+ end
358
+
359
+ # Jump +height+ up and fall back, like a ball: slow at the top, fast
360
+ # near the ground.
361
+ def bounce(height_ = nil, height: height_ || 20, every: 1)
362
+ period = Motion.seconds(every, :bounce, :every, "bounce(height: 20, every: 1)")
363
+ Check.number(height, word: :bounce, name: :height, example: "bounce(height: 20, every: 1)")
364
+ Motion.word(self, period) { |art, p| Asobi.transform(art, Affine.translate(0, -height * 4 * p * (1 - p))) }
365
+ end
366
+
367
+ # Go there and come back, smoothly. With nothing given, to the right by
368
+ # the picture's width.
369
+ def drift(right_ = nil, right: right_, left: nil, up: nil, down: nil, every: 4)
370
+ period = Motion.seconds(every, :drift, :every, "drift(right: 40, every: 4)")
371
+ if [right, left, up, down].all?(&:nil?)
372
+ box = Motion.extent(self)
373
+ right = box ? box.width : 0
374
+ end
375
+ amounts = { right:, left:, up:, down: }.transform_values { _1 || 0 }
376
+ amounts.each { |name, v| Check.number(v, word: :drift, name:, example: "drift(right: 40, every: 4)") }
377
+ dx = amounts[:right] - amounts[:left]
378
+ dy = amounts[:down] - amounts[:up]
379
+ Motion.word(self, period) do |art, p|
380
+ s = Asobi.swell(p)
381
+ Asobi.transform(art, Affine.translate(dx * s, dy * s))
382
+ end
383
+ end
384
+
385
+ # Change colour from +from+ (black if not given) to +to+ and back.
386
+ # shift_color(:gold) black, gold, black ...
387
+ # shift_color(:navy, :gold) navy, gold, navy ...
388
+ def shift_color(first = nil, second = nil, from: nil, to: nil, every: 4)
389
+ if second.nil?
390
+ to ||= first
391
+ else
392
+ from ||= first
393
+ to ||= second
394
+ end
395
+ from ||= :black
396
+ to ||= :crimson
397
+ period = Motion.seconds(every, :shift_color, :every, "shift_color(to: :crimson, every: 4)")
398
+ Color.css(from)
399
+ Color.css(to)
400
+ Motion.word(self, period) { |art, p| Asobi.paint(art, [Color.mix(from, to, Asobi.swell(p))]) }
401
+ end
402
+
403
+ # Your own motion. The block gets the picture and t, which runs from
404
+ # 0.0 up to 1.0 every +seconds+ seconds, and answers the picture then.
405
+ # star.animate(4) { |picture, t| picture.rotate(t * 360).grow(to: 1 + t) }
406
+ def animate(seconds = 4, every: seconds, &block)
407
+ unless block
408
+ raise Messages.error(ArgumentError, :need_block, word: :animate,
409
+ example: "animate(4) { |picture, t| picture.rotate(t * 360) }")
410
+ end
411
+
412
+ period = Motion.seconds(every, :animate, :every, "animate(4) { |picture, t| picture.rotate(t * 360) }")
413
+ Motion.word(self, period) { |art, t| block.call(art, t) }
414
+ end
415
+
416
+ # Only a picture in motion can be staggered: see Motion#one_by_one.
417
+ def one_by_one(*, **)
418
+ raise Messages.error(ArgumentError, :no_motion_word)
419
+ end
420
+ end
421
+
422
+ class << self
423
+ # 0 at the start of a loop, 1 halfway, 0 again at the end; smooth.
424
+ def swell(phase) = (1 - Math.cos(2 * Math::PI * phase)) / 2
425
+
426
+ # art.rb's versions are replaced (not wrapped) by the ones below.
427
+ remove_method :art, :pair, :gather, :change
428
+
429
+ # Pictures only. A Motion is a picture in motion, and is refused with a
430
+ # hint to take one moment of it; things that become motions (a list
431
+ # holding one, an object whose to_art answers one) answer that Motion.
432
+ def art(object, as: nil)
433
+ raise Motion.not_a_picture(object) if object.is_a?(Motion)
434
+
435
+ Motion.reach(object, as:)
436
+ end
437
+
438
+ # The doors of art.rb, widened: when a picture meets a motion, the
439
+ # answer is a motion that does the same at every moment.
440
+ def pair(a, b, &block)
441
+ Motion.combine([Motion.reach(a), Motion.reach(b)]) { |(x, y)| block.call(x, y) }
442
+ end
443
+
444
+ def gather(things, as: nil, &block)
445
+ items = things.to_a
446
+ return Motion.combine([as]) { |(shape)| gather(items, as: shape, &block) } if as.is_a?(Motion)
447
+
448
+ Motion.combine(items.map { Motion.reach(_1, as:) }, &block)
449
+ end
450
+
451
+ def change(thing, &block)
452
+ Motion.combine([Motion.reach(thing)]) { |(art)| block.call(art) }
453
+ end
454
+ end
455
+
456
+ Messages.add(:motion_not_picture,
457
+ en: "%{thing} is a picture in motion, not one still picture. Take the picture at one moment with at, like motion.at(0)",
458
+ ja: "%{thing} は動いている絵で、止まった1枚の絵ではありません。ある時刻の絵は at で取り出せます。例:motion.at(0)")
459
+ Messages.add(:need_positive,
460
+ en: "%{word} needs %{name} to be more than 0 seconds, like %{example}. You gave %{got}.",
461
+ ja: "%{word} の %{name} には 0 より大きい秒数を渡してください(いまは %{got})。例:%{example}")
462
+ Messages.add(:no_motion_word,
463
+ en: "one_by_one staggers a motion word, so write it right after one (spin, pulse, sway ...), like dot.repeat(12).in_ring.pulse(every: 1).one_by_one",
464
+ ja: "one_by_one は動きの言葉(spin、pulse、sway など)の直後に書いてください。例:dot.repeat(12).in_ring.pulse(every: 1).one_by_one")
465
+ Messages.add(:no_reel,
466
+ en: "saving a motion needs Asobi::Reel, which is not loaded. Use require \"asobi\", or save one moment: motion.at(0).save(\"still.svg\"). You gave %{path}.",
467
+ ja: "動きを保存するには Asobi::Reel が必要ですが、読み込まれていません。require \"asobi\" を使うか、ある時刻の絵を保存してください(いまは %{path})。例:motion.at(0).save(\"still.svg\")")
468
+ Messages.add(:bad_motion_save,
469
+ en: "Use motion.save(\"flower.html\") to save the animation or motion.at(0).save(\"still.svg\") to save one frame. You gave %{path}.",
470
+ ja: "アニメーションの保存先には .html を指定してください(いまは %{path})。例:motion.save(\"flower.html\")。静止画は motion.at(0).save(\"still.svg\") で保存できます。")
471
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # The picture values themselves. Each is an immutable Data value centred
5
+ # on the origin, and answers:
6
+ #
7
+ # box(m) the bounding Box once transformed by Affine m (nil if empty)
8
+ # svg an SVG fragment, without the outer <svg> tag
9
+ #
10
+ # Data's own +with+ is removed: asobi has no word "with" (DESIGN §5).
11
+ module Picture
12
+ def self.define(*fields, &)
13
+ Data.define(*fields) do
14
+ include Art
15
+ include Node
16
+ undef_method :with
17
+ class_eval(&)
18
+ end
19
+ end
20
+ end
21
+
22
+ Circle = Picture.define(:r) do
23
+ def box(m) = Asobi::Ellipse.box_of(r, r, m)
24
+ def svg = %(<circle r="#{Asobi.fmt(r)}"/>)
25
+ end
26
+
27
+ Ellipse = Picture.define(:rx, :ry) do
28
+ # Exact box of a transformed ellipse.
29
+ def self.box_of(rx, ry, m)
30
+ hx = Math.hypot(m.a * rx, m.c * ry)
31
+ hy = Math.hypot(m.b * rx, m.d * ry)
32
+ Asobi::Box.new(m.e - hx, m.f - hy, m.e + hx, m.f + hy)
33
+ end
34
+
35
+ def box(m) = Asobi::Ellipse.box_of(rx, ry, m)
36
+ def svg = %(<ellipse rx="#{Asobi.fmt(rx)}" ry="#{Asobi.fmt(ry)}"/>)
37
+ end
38
+
39
+ Rect = Picture.define(:w, :h) do
40
+ def box(m) = Asobi::Box.of_rect(w, h, m)
41
+
42
+ def svg
43
+ %(<rect x="#{Asobi.fmt(-w / 2.0)}" y="#{Asobi.fmt(-h / 2.0)}" ) +
44
+ %(width="#{Asobi.fmt(w)}" height="#{Asobi.fmt(h)}"/>)
45
+ end
46
+ end
47
+
48
+ # Words, centred on the origin. The box is an estimate: about 0.6 of the
49
+ # size per Latin letter, a full size per wide (e.g. Japanese) character.
50
+ Text = Picture.define(:string, :size, :font) do
51
+ def initialize(string:, size: 16.0, font: nil)
52
+ super(string: string.to_s.dup.freeze, size: size.to_f, font: font&.to_s&.freeze)
53
+ end
54
+
55
+ def estimated_width
56
+ string.each_char.sum { |ch| ch.ascii_only? ? 0.6 : 1.0 } * size
57
+ end
58
+
59
+ def box(m) = string.empty? ? nil : Asobi::Box.of_rect(estimated_width, size * 1.2, m)
60
+
61
+ def svg
62
+ %(<text text-anchor="middle" dominant-baseline="central" xml:space="preserve" ) +
63
+ %(font-family="#{Asobi.escape(font || "sans-serif")}" font-size="#{Asobi.fmt(size)}">) +
64
+ "#{Asobi.escape(string)}</text>"
65
+ end
66
+ end
67
+
68
+ # Invisible, but takes up room. Useful as a gap in a row.
69
+ Space = Picture.define(:w, :h) do
70
+ def box(m) = Asobi::Box.of_rect(w, h, m)
71
+ def svg = ""
72
+ end
73
+
74
+ # Any outline made of straight and curved pieces, possibly several of them
75
+ # (a ring is two circles with fill_rule: :evenodd, so the hole is clear).
76
+ #
77
+ # +commands+ is a list of
78
+ # [:move, x, y] start a new piece here
79
+ # [:line, x, y] straight to (x, y)
80
+ # [:cubic, x1, y1, x2, y2, x, y] cubic Bézier to (x, y)
81
+ # [:close] straight back to the piece's start
82
+ #
83
+ # Build one with Path.new(commands: [...], fill_rule: :nonzero), or with
84
+ # the helpers Path.build { |pen| ... }, Path.polyline and Path.smooth.
85
+ # A Path is filled (black) unless styled; open lines are given
86
+ # outlined(:black) by the shape words.
87
+ Path = Picture.define(:commands, :fill_rule) do
88
+ def initialize(commands:, fill_rule: :nonzero)
89
+ clean = commands.map do |command|
90
+ name, *numbers = command
91
+ name = name.to_sym
92
+ unless Asobi::Path::ARITY[name] == numbers.size && numbers.all?(Numeric)
93
+ raise ArgumentError, "Path command #{command.inspect} is not one of " \
94
+ "[:move, x, y], [:line, x, y], [:cubic, x1, y1, x2, y2, x, y], [:close]"
95
+ end
96
+ [name, *numbers.map(&:to_f)].freeze
97
+ end
98
+ unless %i[nonzero evenodd].include?(fill_rule.to_sym)
99
+ raise ArgumentError, "fill_rule is :nonzero or :evenodd (got #{fill_rule.inspect})"
100
+ end
101
+
102
+ super(commands: clean.freeze, fill_rule: fill_rule.to_sym)
103
+ end
104
+
105
+ # Straight lines through [x, y] points.
106
+ def self.polyline(points, closed: false, fill_rule: :nonzero)
107
+ first, *rest = points
108
+ commands = [[:move, *first], *rest.map { [:line, *_1] }]
109
+ commands << [:close] if closed
110
+ new(commands:, fill_rule:)
111
+ end
112
+
113
+ # A smooth curve through every point (Catmull-Rom, drawn as cubic
114
+ # Béziers). Closed curves wrap around smoothly too.
115
+ def self.smooth(points, closed: false, fill_rule: :nonzero)
116
+ return polyline(points, closed:, fill_rule:) if points.size < 3
117
+
118
+ n = points.size
119
+ at = closed ? ->(i) { points[i % n] } : ->(i) { points[i.clamp(0, n - 1)] }
120
+ commands = [[:move, *points[0]]]
121
+ (closed ? n : n - 1).times do |i|
122
+ p0, p1, p2, p3 = at.(i - 1), at.(i), at.(i + 1), at.(i + 2)
123
+ c1 = [p1[0] + (p2[0] - p0[0]) / 6.0, p1[1] + (p2[1] - p0[1]) / 6.0]
124
+ c2 = [p2[0] - (p3[0] - p1[0]) / 6.0, p2[1] - (p3[1] - p1[1]) / 6.0]
125
+ commands << [:cubic, *c1, *c2, *p2]
126
+ end
127
+ commands << [:close] if closed
128
+ new(commands:, fill_rule:)
129
+ end
130
+
131
+ # Path.build(fill_rule: :evenodd) do |pen|
132
+ # pen.move(0, -10).curve(5, -10, 10, -5, 10, 0).line(0, 10).close
133
+ # end
134
+ def self.build(fill_rule: :nonzero)
135
+ pen = Asobi::Path::Pen.new
136
+ yield pen
137
+ new(commands: pen.commands, fill_rule:)
138
+ end
139
+
140
+ def closed? = commands.any? { _1.first == :close }
141
+
142
+ # Exact box, curves included: an affine image of a Bézier is the Bézier
143
+ # of the transformed control points.
144
+ def box(m)
145
+ return nil if commands.empty?
146
+
147
+ xs = []
148
+ ys = []
149
+ current = [0.0, 0.0]
150
+ commands.each do |name, *n|
151
+ case name
152
+ when :move, :line
153
+ current = m.apply(n[0], n[1])
154
+ xs << current[0]
155
+ ys << current[1]
156
+ when :cubic
157
+ c1 = m.apply(n[0], n[1])
158
+ c2 = m.apply(n[2], n[3])
159
+ to = m.apply(n[4], n[5])
160
+ xs.concat(Asobi::Geometry.cubic_extent(current[0], c1[0], c2[0], to[0]))
161
+ ys.concat(Asobi::Geometry.cubic_extent(current[1], c1[1], c2[1], to[1]))
162
+ current = to
163
+ end
164
+ end
165
+ Asobi::Box.new(xs.min, ys.min, xs.max, ys.max)
166
+ end
167
+
168
+ def svg
169
+ letters = { move: "M", line: "L", cubic: "C", close: "Z" }
170
+ d = commands.map { |name, *n| [letters[name], *n.map { Asobi.fmt(_1) }].join(" ") }.join(" ")
171
+ rule = fill_rule == :evenodd ? %( fill-rule="evenodd") : ""
172
+ %(<path d="#{d}"#{rule}/>)
173
+ end
174
+ end
175
+
176
+ class Path
177
+ ARITY = { move: 2, line: 2, cubic: 6, close: 0 }.freeze
178
+
179
+ # Collects path commands for Path.build.
180
+ class Pen
181
+ attr_reader :commands
182
+
183
+ def initialize = @commands = []
184
+ def move(x, y) = add(:move, x, y)
185
+ def line(x, y) = add(:line, x, y)
186
+ def curve(x1, y1, x2, y2, x, y) = add(:cubic, x1, y1, x2, y2, x, y)
187
+ def close = add(:close)
188
+
189
+ private
190
+
191
+ def add(*command)
192
+ @commands << command
193
+ self
194
+ end
195
+ end
196
+ end
197
+
198
+ # A picture with colours, lines or opacity. Built by Asobi.restyle.
199
+ Styled = Picture.define(:art, :style) do
200
+ def initialize(art:, style:)
201
+ super(art:, style: Asobi::Style.clean(style))
202
+ end
203
+
204
+ def box(m)
205
+ inner = art.box(m)
206
+ inner = inner.grow(Asobi::Style.stroke_width(style) / 2.0 * m.stretch) if inner && Asobi::Style.stroked?(style)
207
+ Asobi::Effects.grow(inner, style[:effects], m)
208
+ end
209
+
210
+ def svg = Asobi::Effects.render(style, art.svg) { art.box(IDENTITY) }
211
+ end
212
+
213
+ # A picture moved, turned or resized. Built by Asobi.transform.
214
+ Transformed = Picture.define(:art, :matrix) do
215
+ def box(m) = art.box(m * matrix)
216
+
217
+ def svg
218
+ inner = art.svg
219
+ return "" if inner.empty?
220
+
221
+ t =
222
+ if matrix.translation?
223
+ "translate(#{Asobi.fmt(matrix.e)} #{Asobi.fmt(matrix.f)})"
224
+ else
225
+ "matrix(#{matrix.to_h.values.map { Asobi.fmt(_1) }.join(" ")})"
226
+ end
227
+ %(<g transform="#{t}">#{inner}</g>)
228
+ end
229
+ end
230
+ end