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.
data/lib/asobi/art.rb ADDED
@@ -0,0 +1,498 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # Asobi::Art is to pictures what Enumerable is to collections.
5
+ #
6
+ # Enumerable asks you for one method, +each+, and gives you everything
7
+ # else. Asobi::Art asks you for one method, +to_art+, and gives you every
8
+ # word in this module:
9
+ #
10
+ # class Flower
11
+ # include Asobi::Art
12
+ # def to_art = ellipse(width: 12, height: 32).move(up: 22).repeat(8).in_fan
13
+ # end
14
+ #
15
+ # Flower.new.rotate(15).colored(:crimson).save("flower.svg")
16
+ #
17
+ # Every word answers a new picture. Nothing is drawn "onto" anything, and
18
+ # no picture is ever changed in place.
19
+ module Art
20
+ # The shape words (circle, rect ...) are available, privately, inside
21
+ # any class that includes Art, even without require "asobi".
22
+ include Shapes
23
+ private(*Shapes::WORDS)
24
+
25
+ # Where save sends each kind of file. Later parts of asobi add more
26
+ # (".html" for motion), as Asobi::Art::SAVERS[".html"] = ->(art, path, **options) { ... }
27
+ SAVERS = {
28
+ ".svg" => ->(art, path, **options) { File.write(path, SVG.document(Asobi.art(art), **options)) }
29
+ }
30
+
31
+ # --- combining ---------------------------------------------------------
32
+
33
+ # This picture on top of +other+.
34
+ def on(other) = Asobi.pair(self, other) { |a, b| Many.new([b, a]) }
35
+
36
+ # +other+ on top of this picture.
37
+ def under(other) = Asobi.pair(self, other) { |a, b| Many.new([a, b]) }
38
+
39
+ # a + b is a.under(b): what you write later lies on top.
40
+ def +(other) = under(other)
41
+
42
+ # Copies in the same place, until you arrange them. a * 12
43
+ def repeat(count = 2, times: count)
44
+ n = Check.whole(times, word: :repeat, name: :times, example: "repeat(12)")
45
+ Asobi.change(self) { |art| Many.new(Array.new(n, art)) }
46
+ end
47
+
48
+ def *(count) = repeat(count)
49
+
50
+ # Side by side, this one on the left.
51
+ def beside(other, gap: 0)
52
+ Check.number(gap, word: :beside, name: :gap, example: "a.beside(b, gap: 4)")
53
+ Asobi.pair(self, other) { |a, b| Many.new([a, b]).in_row(gap:) }
54
+ end
55
+
56
+ # Stacked, this one on top.
57
+ def above(other, gap: 0)
58
+ Check.number(gap, word: :above, name: :gap, example: "a.above(b, gap: 4)")
59
+ Asobi.pair(self, other) { |a, b| Many.new([a, b]).in_column(gap:) }
60
+ end
61
+
62
+ # --- changing ----------------------------------------------------------
63
+ # Every change happens around the origin (the centre of a fresh shape).
64
+
65
+ def move(right: 0, left: 0, up: 0, down: 0)
66
+ { right:, left:, up:, down: }.each do |name, v|
67
+ Check.number(v, word: :move, name:, example: "move(right: 10, up: 5)")
68
+ end
69
+ Asobi.change(self) { |art| Asobi.transform(art, Affine.translate(right - left, down - up)) }
70
+ end
71
+
72
+ # Clockwise, in degrees. rotate(45), rotate(0.25.turn)
73
+ def rotate(degrees = 45)
74
+ Check.number(degrees, word: :rotate, name: :degrees, example: "rotate(45)")
75
+ Asobi.change(self) { |art| Asobi.transform(art, Affine.rotate(degrees)) }
76
+ end
77
+
78
+ # scale(2), or scale(width: 2, height: 1) to stretch one way only.
79
+ def scale(factor = nil, width: nil, height: nil)
80
+ factor = 2 if factor.nil? && width.nil? && height.nil?
81
+ w = width || factor || 1
82
+ h = height || factor || 1
83
+ Check.number(w, word: :scale, name: :width, example: "scale(width: 2, height: 1)")
84
+ Check.number(h, word: :scale, name: :height, example: "scale(width: 2, height: 1)")
85
+ Asobi.change(self) { |art| Asobi.transform(art, Affine.scale(w, h)) }
86
+ end
87
+
88
+ def grow(amount = nil, to: amount || 2)
89
+ Check.number(to, word: :grow, name: :to, example: "grow(to: 2)")
90
+ scale(to)
91
+ end
92
+
93
+ def shrink(amount = nil, to: amount || 0.5)
94
+ Check.number(to, word: :shrink, name: :to, example: "shrink(to: 0.5)")
95
+ scale(to)
96
+ end
97
+
98
+ # Left becomes right.
99
+ def mirror = scale(width: -1, height: 1)
100
+
101
+ # Top becomes bottom.
102
+ def flip = scale(width: 1, height: -1)
103
+
104
+ # --- colouring ---------------------------------------------------------
105
+
106
+ # One colour paints the picture. Several colours on a Many go round its
107
+ # pictures in turn; on a single picture the first one is used.
108
+ #
109
+ # With from: or to: (or angle:, around:) it is a gradient instead:
110
+ #
111
+ # colored(from: :gold, to: :crimson) top to bottom
112
+ # colored(from: :gold, to: :crimson, angle: 90) turned 90 degrees clockwise
113
+ # colored(from: :white, to: :navy, around: true) from the centre outwards
114
+ # colored(:gold, :crimson, around: true) the two colours, as from and to
115
+ #
116
+ # A missing from is crimson and a missing to is navy. On a Many the
117
+ # gradient flows once across the whole group. It belongs to the
118
+ # picture: move, turn or resize the picture and the gradient goes along.
119
+ def colored(*colors, from: nil, to: nil, angle: nil, around: nil)
120
+ if [from, to, angle, around].any? { !_1.nil? }
121
+ gradient = Gradient.make(from || colors[0] || :crimson, to || colors[1] || :navy, angle || 0, around || false)
122
+ return Asobi.change(self) { |art| Asobi.paint(art, [gradient]) }
123
+ end
124
+
125
+ colors = [:crimson] if colors.empty?
126
+ paints = colors.map { Color.css(_1) }
127
+ Asobi.change(self) { |art| Asobi.paint(art, paints) }
128
+ end
129
+
130
+ # No fill, just the line.
131
+ def outlined(color = :black, width: 1)
132
+ line = Color.css(color)
133
+ Check.number(width, word: :outlined, name: :width, example: "outlined(:black, width: 1)", min: 0)
134
+ Asobi.change(self) { |art| Asobi.restyle(art) { |s| s.merge(fill: "none", stroke: line, stroke_width: width.to_f) } }
135
+ end
136
+
137
+ # Keep the fill, add a line around it.
138
+ def stroke(color = :black, width: 1)
139
+ line = Color.css(color)
140
+ Check.number(width, word: :stroke, name: :width, example: "stroke(:black, width: 1)", min: 0)
141
+ Asobi.change(self) { |art| Asobi.restyle(art) { |s| s.merge(stroke: line, stroke_width: width.to_f) } }
142
+ end
143
+
144
+ # 0 is invisible, 1 is solid.
145
+ def opacity(value = 0.5)
146
+ Check.number(value, word: :opacity, name: :value, example: "opacity(0.5)", min: 0, max: 1)
147
+ Asobi.change(self) { |art| Asobi.restyle(art) { |s| s.merge(opacity: value.to_f) } }
148
+ end
149
+
150
+ # --- effects -----------------------------------------------------------
151
+ # Sizes are lengths in the picture, so effects grow and shrink with it.
152
+ # The same effect given again replaces the old one.
153
+
154
+ # A soft light around the edges, +size+ wide, in the picture's own
155
+ # colours (or in +color+). glowing, glowing(8), glowing(color: :gold)
156
+ def glowing(size_ = nil, size: size_ || 4, color: nil)
157
+ Check.number(size, word: :glowing, name: :size, example: "glowing(size: 4)", min: 0)
158
+ paint = color.nil? ? nil : Color.css(color)
159
+ Asobi.change(self) { |art| Asobi.affect(art, :glow, { size: size.to_f, color: paint }.freeze) }
160
+ end
161
+
162
+ # Out of focus. blurred, blurred(4)
163
+ def blurred(amount_ = nil, amount: amount_ || 2)
164
+ Check.number(amount, word: :blurred, name: :amount, example: "blurred(amount: 2)", min: 0)
165
+ Asobi.change(self) { |art| Asobi.affect(art, :blur, amount.to_f) }
166
+ end
167
+
168
+ # How the picture mixes with what lies under it:
169
+ # :screen (lighter where they overlap), :multiply (darker), :overlay,
170
+ # :lighten, :darken, :difference, :normal.
171
+ def blended(mode = :screen)
172
+ name = mode.to_s.downcase
173
+ unless (mode.is_a?(Symbol) || mode.is_a?(String)) && Effects::BLENDS.include?(name)
174
+ raise Messages.error(ArgumentError, :bad_blend, got: mode.inspect,
175
+ modes: Effects::BLENDS.map { ":#{_1}" }.join(", "))
176
+ end
177
+
178
+ Asobi.change(self) { |art| Asobi.affect(art, :blend, name.freeze) }
179
+ end
180
+
181
+ # A soft shadow, +down+ and +right+ from the picture.
182
+ def shadowed(down_ = nil, down: down_ || 2, right: 0, blur: 3, color: :black, opacity: 0.4)
183
+ example = "shadowed(down: 2, right: 0, blur: 3)"
184
+ Check.number(down, word: :shadowed, name: :down, example:)
185
+ Check.number(right, word: :shadowed, name: :right, example:)
186
+ Check.number(blur, word: :shadowed, name: :blur, example:, min: 0)
187
+ Check.number(opacity, word: :shadowed, name: :opacity, example: "shadowed(opacity: 0.4)", min: 0, max: 1)
188
+ shade = { right: right.to_f, down: down.to_f, blur: blur.to_f, color: Color.css(color), opacity: opacity.to_f }.freeze
189
+ Asobi.change(self) { |art| Asobi.affect(art, :shadow, shade) }
190
+ end
191
+
192
+ # Only what shows through +shape+, as if through a window. The shape is
193
+ # used where it is. flower.clipped_by(circle(20))
194
+ # Without a shape: a round window 0.8 of the picture's narrower side across.
195
+ def clipped_by(shape = nil)
196
+ if shape.nil? # a round window, 0.8 of the narrower side across
197
+ box = defined?(Motion) ? Motion.extent(self) : Asobi.art(self).box(IDENTITY)
198
+ box ||= Box.new(-10.0, -10.0, 10.0, 10.0)
199
+ shape = Asobi.transform(Asobi.circle(radius: [[box.width, box.height].min * 0.4, 0.001].max),
200
+ Affine.translate(box.cx, box.cy))
201
+ end
202
+ Asobi.pair(self, shape) { |art, window| Asobi.affect(art, :clip, window) }
203
+ end
204
+
205
+ # Earlier moments drawn under a moving picture, fainter and fainter.
206
+ # A still picture has no past: it stays as it is.
207
+ def trail(count_ = nil, count: count_ || 5, gap: nil)
208
+ n = Check.whole(count, word: :trail, name: :count, example: "trail(count: 5, gap: 0.05)", min: 1)
209
+ Check.number(gap, word: :trail, name: :gap, example: "trail(count: 5, gap: 0.05)", min: 0) unless gap.nil?
210
+ return self unless defined?(Motion)
211
+
212
+ found = Motion.reach(self)
213
+ found.is_a?(Motion) ? found.trail(count: n, gap:) : self
214
+ end
215
+
216
+ # --- arranging ---------------------------------------------------------
217
+ # The words of Asobi::Many work on every picture: a single picture is a
218
+ # group of one, so circle.in_ring or heart.scattered just work.
219
+ ARRANGING = %i[in_row in_column in_grid in_ring in_fan in_spiral scattered change_each].freeze
220
+
221
+ ARRANGING.each do |word|
222
+ define_method(word) do |*args, **options, &block|
223
+ Asobi.change(self) { |art| Many.of(art).public_send(word, *args, **options, &block) }
224
+ end
225
+ end
226
+
227
+ # --- measuring ---------------------------------------------------------
228
+
229
+ def bounds = Asobi.art(self).box(IDENTITY) || Box.new(0.0, 0.0, 0.0, 0.0)
230
+ def width = bounds.width
231
+ def height = bounds.height
232
+
233
+ # --- leaving Ruby ------------------------------------------------------
234
+
235
+ def to_svg(**options) = SVG.document(Asobi.art(self), **options)
236
+
237
+ def save(path, **options)
238
+ saver = SAVERS[File.extname(path.to_s).downcase]
239
+ unless saver
240
+ raise Messages.error(ArgumentError, :bad_save, got: path.inspect, kinds: SAVERS.keys.join(" / "))
241
+ end
242
+
243
+ saver.call(self, path.to_s, **options)
244
+ path
245
+ end
246
+ end
247
+
248
+ # Marker for the built-in picture values. Each answers box(affine) (its
249
+ # bounding box under a transform, or nil when empty) and svg (a fragment).
250
+ module Node
251
+ def to_art = self
252
+ end
253
+
254
+ class << self
255
+ # Ask anything to become a picture.
256
+ #
257
+ # Asobi.art(8) a disc of radius 8
258
+ # Asobi.art([3, 1, 4], as: star) three stars in a row
259
+ # Asobi.art(planet) whatever planet.to_art answers
260
+ def art(object, as: nil)
261
+ original = object
262
+ 16.times do
263
+ return object if object.is_a?(Node)
264
+
265
+ built_in = Convert.call(object, as)
266
+ return built_in if built_in
267
+ raise Messages.error(TypeError, :not_art, thing: describe(original)) unless object.respond_to?(:to_art)
268
+
269
+ object = object.to_art
270
+ end
271
+ raise Messages.error(TypeError, :to_art_loop, thing: describe(original))
272
+ end
273
+
274
+ # The one door every two-picture word goes through (on, under, +,
275
+ # beside, above). Later parts of asobi widen it to pictures in motion.
276
+ def pair(a, b) = yield(art(a), art(b))
277
+
278
+ # The one door every list of things goes through on its way to becoming
279
+ # pictures (Array, Range and Hash to_art, change_each ...).
280
+ def gather(things, as: nil) = yield(things.to_a.map { art(_1, as:) })
281
+
282
+ # The one door every one-picture word (move, colored, repeat ...) goes
283
+ # through.
284
+ def change(thing) = yield(art(thing))
285
+
286
+ # --- building blocks for the words -------------------------------------
287
+
288
+ # Apply an affine transform. A Many passes it on to each of its
289
+ # pictures, so it stays a Many you can still arrange; its own effects
290
+ # and gradient are moved, turned and resized to match.
291
+ def transform(node, matrix)
292
+ case node
293
+ when Many then Many.new(node.items.map { transform(_1, matrix) }, Style.transform(node.style, matrix))
294
+ when Transformed
295
+ combined = matrix * node.matrix
296
+ combined.identity? ? node.art : Transformed.new(node.art, combined)
297
+ else
298
+ matrix.identity? ? node : Transformed.new(node, matrix)
299
+ end
300
+ end
301
+
302
+ # Change the style of a picture. The block gets the current style Hash
303
+ # and answers the new one. Styling the same picture again replaces what
304
+ # was there: the latest word wins.
305
+ def restyle(node, &edit)
306
+ case node
307
+ when Transformed then Transformed.new(restyle(node.art, &edit), node.matrix)
308
+ when Styled
309
+ # Effects put around a moved picture stay outside; colours go in.
310
+ return Styled.new(restyle(node.art, &edit), node.style) if Style.effects_only?(node.style)
311
+
312
+ Styled.new(node.art, edit.call(node.style))
313
+ when Many then Many.new(node.items, edit.call(node.style))
314
+ else Styled.new(node, edit.call({}))
315
+ end
316
+ end
317
+
318
+ # Paint with one colour, or (on a Many) cycle several over its pictures.
319
+ # A picture that is only a line (fill: none) gets its line recoloured.
320
+ def paint(node, colors)
321
+ case node
322
+ when Transformed
323
+ # A gradient keeps the direction it was given as seen from outside.
324
+ inside = colors.map { _1.is_a?(Gradient) ? _1.through(node.matrix.inverse_linear) : _1 }
325
+ Transformed.new(paint(node.art, inside), node.matrix)
326
+ when Styled
327
+ return Styled.new(paint(node.art, colors), node.style) if Style.effects_only?(node.style)
328
+
329
+ restyle(node) { |s| Style.paint(s, colors.first) }
330
+ when Many
331
+ return restyle(node) { |s| Style.paint(s, colors.first) } if colors.size == 1
332
+
333
+ items = node.items.each_with_index.map { |item, i| paint(item, [colors[i % colors.size]]) }
334
+ Many.new(items, node.style)
335
+ else
336
+ restyle(node) { |s| Style.paint(s, colors.first) }
337
+ end
338
+ end
339
+
340
+ # Put an effect (:glow, :blur, :shadow, :clip, :blend) on a picture. The
341
+ # same effect again replaces the old one.
342
+ def affect(node, key, value)
343
+ case node
344
+ when Many then Many.new(node.items, node.style.merge(effects: Effects.put(node.style[:effects], key, value)))
345
+ when Styled
346
+ Styled.new(Effects.strip(node.art, key), node.style.merge(effects: Effects.put(node.style[:effects], key, value)))
347
+ else Styled.new(Effects.strip(node, key), { effects: Effects.put(nil, key, value) })
348
+ end
349
+ end
350
+
351
+ # Numbers as SVG likes them: short, no trailing zeros, never "-0".
352
+ def fmt(number)
353
+ rounded = number.to_f.round(3)
354
+ rounded = 0.0 if rounded.zero?
355
+ rounded == rounded.to_i ? rounded.to_i.to_s : rounded.to_s
356
+ end
357
+
358
+ def escape(text)
359
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
360
+ end
361
+
362
+ private
363
+
364
+ def describe(object)
365
+ text = object.inspect
366
+ text = "#{text[0, 40]}..." if text.size > 43
367
+ "#{text} (#{object.class})"
368
+ end
369
+ end
370
+
371
+ # Styles are small frozen Hashes: :fill, :stroke, :stroke_width, :opacity,
372
+ # and :effects (an ordered Hash of glow, blur, shadow, clip, blend). Fill
373
+ # and stroke are CSS colour strings or a Gradient.
374
+ module Style
375
+ ORDER = %i[fill stroke stroke_width opacity effects].freeze
376
+ PAINT = %i[fill stroke stroke_width opacity].freeze
377
+
378
+ module_function
379
+
380
+ def clean(style) = style.slice(*ORDER).sort_by { ORDER.index(_1.first) }.to_h.freeze
381
+
382
+ # Colour a picture: its fill, or its line when it has no fill.
383
+ def paint(style, color)
384
+ if style[:fill] == "none" && style[:stroke] && style[:stroke] != "none"
385
+ style.merge(stroke: color)
386
+ else
387
+ style.merge(fill: color)
388
+ end
389
+ end
390
+
391
+ def stroked?(style) = style[:stroke] && style[:stroke] != "none"
392
+ def stroke_width(style) = style[:stroke_width] || 1.0
393
+ def effects_only?(style) = style.key?(:effects) && (style.keys & PAINT).empty?
394
+ def plain?(style) = !style.key?(:effects) && style.values.none?(Gradient)
395
+
396
+ # The style after the picture it belongs to is moved by +matrix+ (used
397
+ # by a Many, whose pictures are moved one by one).
398
+ def transform(style, matrix)
399
+ return style if plain?(style)
400
+
401
+ changed = style.to_h do |key, value|
402
+ case value
403
+ when Gradient then [key, value.through(matrix)]
404
+ else [key, key == :effects ? Effects.transform(value, matrix) : value]
405
+ end
406
+ end
407
+ changed.freeze
408
+ end
409
+
410
+ # SVG attributes. +urls+ gives the id for each Gradient.
411
+ def attributes(style, urls = {})
412
+ style.filter_map do |key, value|
413
+ next if key == :effects
414
+
415
+ text =
416
+ case value
417
+ when Numeric then Asobi.fmt(value)
418
+ when Gradient then "url(##{urls.fetch(value)})"
419
+ else Asobi.escape(value)
420
+ end
421
+ %( #{key.to_s.tr("_", "-")}="#{text}")
422
+ end.join
423
+ end
424
+ end
425
+ end
426
+
427
+ module Asobi
428
+ # How Ruby's own objects become pictures (DESIGN §7). This lives here,
429
+ # not in core_ext, so that require "asobi/pure" can do Asobi.art(8)
430
+ # without touching Integer.
431
+ module Convert
432
+ GAP = 4
433
+
434
+ module_function
435
+
436
+ # A picture, or nil when +object+ is not one of Ruby's own kinds.
437
+ def call(object, as)
438
+ case object
439
+ when nil then Asobi.nothing
440
+ when Numeric then object.real? ? number(object, as) : nil
441
+ when String then Asobi.text(object)
442
+ when Symbol then Asobi.text(object.to_s)
443
+ when Array then sequence(object, as)
444
+ when Range then sequence(range_items(object), as)
445
+ when Hash then table(object, as)
446
+ end
447
+ end
448
+
449
+ # A size: a disc of that radius, or +as+ resized so that half of its
450
+ # larger side is that size. Negative: the same, as a line only.
451
+ def number(n, as)
452
+ Check.number(n, word: :to_art, name: :number, example: "8.to_art")
453
+ return Asobi.nothing if n.zero?
454
+
455
+ size = n.abs.to_f
456
+ picture =
457
+ if as.nil?
458
+ Asobi.circle(radius: size)
459
+ else
460
+ shape = Asobi.art(as)
461
+ box = shape.box(IDENTITY)
462
+ half = box && [box.width, box.height].max / 2.0
463
+ half && half.positive? ? shape.scale(size / half) : shape
464
+ end
465
+ n.negative? ? picture.outlined : picture
466
+ end
467
+
468
+ # Side by side with a small gap; a list of lists becomes stacked rows.
469
+ def sequence(items, as)
470
+ Asobi.gather(items, as:) do |arts|
471
+ many = Many.new(arts)
472
+ items.any?(Array) ? many.in_column(gap: GAP) : many.in_row(gap: GAP)
473
+ end
474
+ end
475
+
476
+ # One row per pair, "key beside value", stacked.
477
+ def table(hash, as)
478
+ Asobi.gather(hash.to_a.flatten(1), as:) do |arts|
479
+ rows = arts.each_slice(2).map { |key, value| Many.new([key, value]).in_row(gap: GAP) }
480
+ Many.new(rows).in_column(gap: GAP)
481
+ end
482
+ end
483
+
484
+ def range_items(range)
485
+ if range.end.nil? || range.begin.nil? || (range.end.is_a?(Float) && range.end.infinite?)
486
+ raise Messages.error(ArgumentError, :endless, got: range.inspect)
487
+ end
488
+
489
+ range.to_a
490
+ rescue TypeError
491
+ raise Messages.error(ArgumentError, :endless, got: range.inspect)
492
+ end
493
+ end
494
+ end
495
+
496
+ Asobi::Messages.add(:bad_blend,
497
+ en: "blended can mix in these ways: %{modes}, like blended(:screen). You gave %{got}.",
498
+ ja: "blended の混ざり方は %{modes} から選んでください(いまは %{got})。例:blended(:screen)")