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.
- checksums.yaml +7 -0
- data/DESIGN.md +388 -0
- data/LICENSE +21 -0
- data/README.ja.md +154 -0
- data/README.md +157 -0
- data/docs/first-sketch.svg +1 -0
- data/exe/asobi +106 -0
- data/lib/asobi/art.rb +498 -0
- data/lib/asobi/color.rb +236 -0
- data/lib/asobi/core_ext.rb +28 -0
- data/lib/asobi/geometry.rb +126 -0
- data/lib/asobi/live.rb +256 -0
- data/lib/asobi/many.rb +235 -0
- data/lib/asobi/messages.rb +155 -0
- data/lib/asobi/motion.rb +471 -0
- data/lib/asobi/nodes.rb +230 -0
- data/lib/asobi/pure.rb +21 -0
- data/lib/asobi/random.rb +51 -0
- data/lib/asobi/reel.rb +113 -0
- data/lib/asobi/shapes.rb +314 -0
- data/lib/asobi/sketch.rb +207 -0
- data/lib/asobi/svg.rb +352 -0
- data/lib/asobi/top_level.rb +14 -0
- data/lib/asobi/version.rb +5 -0
- data/lib/asobi/web/live.html +34 -0
- data/lib/asobi/web/player.js +295 -0
- data/lib/asobi.rb +12 -0
- metadata +73 -0
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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
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)")
|