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/color.rb
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asobi
|
|
4
|
+
# Colours are plain values: a CSS colour name as a Symbol (:crimson), or a
|
|
5
|
+
# String such as "#ff0066", "hsl(200, 70%, 50%)" or "rgb(10, 20, 30)".
|
|
6
|
+
# This module checks them, turns them into SVG paint, and mixes them.
|
|
7
|
+
module Color
|
|
8
|
+
# The CSS named colours (CSS Color Module Level 4), name => [r, g, b].
|
|
9
|
+
NAMES = {
|
|
10
|
+
"aliceblue" => [240, 248, 255], "antiquewhite" => [250, 235, 215], "aqua" => [0, 255, 255],
|
|
11
|
+
"aquamarine" => [127, 255, 212], "azure" => [240, 255, 255], "beige" => [245, 245, 220],
|
|
12
|
+
"bisque" => [255, 228, 196], "black" => [0, 0, 0], "blanchedalmond" => [255, 235, 205],
|
|
13
|
+
"blue" => [0, 0, 255], "blueviolet" => [138, 43, 226], "brown" => [165, 42, 42],
|
|
14
|
+
"burlywood" => [222, 184, 135], "cadetblue" => [95, 158, 160], "chartreuse" => [127, 255, 0],
|
|
15
|
+
"chocolate" => [210, 105, 30], "coral" => [255, 127, 80], "cornflowerblue" => [100, 149, 237],
|
|
16
|
+
"cornsilk" => [255, 248, 220], "crimson" => [220, 20, 60], "cyan" => [0, 255, 255],
|
|
17
|
+
"darkblue" => [0, 0, 139], "darkcyan" => [0, 139, 139], "darkgoldenrod" => [184, 134, 11],
|
|
18
|
+
"darkgray" => [169, 169, 169], "darkgreen" => [0, 100, 0], "darkgrey" => [169, 169, 169],
|
|
19
|
+
"darkkhaki" => [189, 183, 107], "darkmagenta" => [139, 0, 139], "darkolivegreen" => [85, 107, 47],
|
|
20
|
+
"darkorange" => [255, 140, 0], "darkorchid" => [153, 50, 204], "darkred" => [139, 0, 0],
|
|
21
|
+
"darksalmon" => [233, 150, 122], "darkseagreen" => [143, 188, 143], "darkslateblue" => [72, 61, 139],
|
|
22
|
+
"darkslategray" => [47, 79, 79], "darkslategrey" => [47, 79, 79], "darkturquoise" => [0, 206, 209],
|
|
23
|
+
"darkviolet" => [148, 0, 211], "deeppink" => [255, 20, 147], "deepskyblue" => [0, 191, 255],
|
|
24
|
+
"dimgray" => [105, 105, 105], "dimgrey" => [105, 105, 105], "dodgerblue" => [30, 144, 255],
|
|
25
|
+
"firebrick" => [178, 34, 34], "floralwhite" => [255, 250, 240], "forestgreen" => [34, 139, 34],
|
|
26
|
+
"fuchsia" => [255, 0, 255], "gainsboro" => [220, 220, 220], "ghostwhite" => [248, 248, 255],
|
|
27
|
+
"gold" => [255, 215, 0], "goldenrod" => [218, 165, 32], "gray" => [128, 128, 128],
|
|
28
|
+
"green" => [0, 128, 0], "greenyellow" => [173, 255, 47], "grey" => [128, 128, 128],
|
|
29
|
+
"honeydew" => [240, 255, 240], "hotpink" => [255, 105, 180], "indianred" => [205, 92, 92],
|
|
30
|
+
"indigo" => [75, 0, 130], "ivory" => [255, 255, 240], "khaki" => [240, 230, 140],
|
|
31
|
+
"lavender" => [230, 230, 250], "lavenderblush" => [255, 240, 245], "lawngreen" => [124, 252, 0],
|
|
32
|
+
"lemonchiffon" => [255, 250, 205], "lightblue" => [173, 216, 230], "lightcoral" => [240, 128, 128],
|
|
33
|
+
"lightcyan" => [224, 255, 255], "lightgoldenrodyellow" => [250, 250, 210], "lightgray" => [211, 211, 211],
|
|
34
|
+
"lightgreen" => [144, 238, 144], "lightgrey" => [211, 211, 211], "lightpink" => [255, 182, 193],
|
|
35
|
+
"lightsalmon" => [255, 160, 122], "lightseagreen" => [32, 178, 170], "lightskyblue" => [135, 206, 250],
|
|
36
|
+
"lightslategray" => [119, 136, 153], "lightslategrey" => [119, 136, 153], "lightsteelblue" => [176, 196, 222],
|
|
37
|
+
"lightyellow" => [255, 255, 224], "lime" => [0, 255, 0], "limegreen" => [50, 205, 50],
|
|
38
|
+
"linen" => [250, 240, 230], "magenta" => [255, 0, 255], "maroon" => [128, 0, 0],
|
|
39
|
+
"mediumaquamarine" => [102, 205, 170], "mediumblue" => [0, 0, 205], "mediumorchid" => [186, 85, 211],
|
|
40
|
+
"mediumpurple" => [147, 112, 219], "mediumseagreen" => [60, 179, 113], "mediumslateblue" => [123, 104, 238],
|
|
41
|
+
"mediumspringgreen" => [0, 250, 154], "mediumturquoise" => [72, 209, 204], "mediumvioletred" => [199, 21, 133],
|
|
42
|
+
"midnightblue" => [25, 25, 112], "mintcream" => [245, 255, 250], "mistyrose" => [255, 228, 225],
|
|
43
|
+
"moccasin" => [255, 228, 181], "navajowhite" => [255, 222, 173], "navy" => [0, 0, 128],
|
|
44
|
+
"oldlace" => [253, 245, 230], "olive" => [128, 128, 0], "olivedrab" => [107, 142, 35],
|
|
45
|
+
"orange" => [255, 165, 0], "orangered" => [255, 69, 0], "orchid" => [218, 112, 214],
|
|
46
|
+
"palegoldenrod" => [238, 232, 170], "palegreen" => [152, 251, 152], "paleturquoise" => [175, 238, 238],
|
|
47
|
+
"palevioletred" => [219, 112, 147], "papayawhip" => [255, 239, 213], "peachpuff" => [255, 218, 185],
|
|
48
|
+
"peru" => [205, 133, 63], "pink" => [255, 192, 203], "plum" => [221, 160, 221],
|
|
49
|
+
"powderblue" => [176, 224, 230], "purple" => [128, 0, 128], "rebeccapurple" => [102, 51, 153],
|
|
50
|
+
"red" => [255, 0, 0], "rosybrown" => [188, 143, 143], "royalblue" => [65, 105, 225],
|
|
51
|
+
"saddlebrown" => [139, 69, 19], "salmon" => [250, 128, 114], "sandybrown" => [244, 164, 96],
|
|
52
|
+
"seagreen" => [46, 139, 87], "seashell" => [255, 245, 238], "sienna" => [160, 82, 45],
|
|
53
|
+
"silver" => [192, 192, 192], "skyblue" => [135, 206, 235], "slateblue" => [106, 90, 205],
|
|
54
|
+
"slategray" => [112, 128, 144], "slategrey" => [112, 128, 144], "snow" => [255, 250, 250],
|
|
55
|
+
"springgreen" => [0, 255, 127], "steelblue" => [70, 130, 180], "tan" => [210, 180, 140],
|
|
56
|
+
"teal" => [0, 128, 128], "thistle" => [216, 191, 216], "tomato" => [255, 99, 71],
|
|
57
|
+
"turquoise" => [64, 224, 208], "violet" => [238, 130, 238], "wheat" => [245, 222, 179],
|
|
58
|
+
"white" => [255, 255, 255], "whitesmoke" => [245, 245, 245], "yellow" => [255, 255, 0],
|
|
59
|
+
"yellowgreen" => [154, 205, 50]
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
NUMBER = /\s*(-?\d+(?:\.\d+)?|-?\.\d+)\s*/
|
|
63
|
+
HEX = /\A#(\h{3}|\h{4}|\h{6}|\h{8})\z/
|
|
64
|
+
HSL = /\Ahsla?\(#{NUMBER}(?:deg)?,#{NUMBER}%,#{NUMBER}%(?:,#{NUMBER}(%?))?\)\z/i
|
|
65
|
+
RGB = /\Argba?\(#{NUMBER}(%?),#{NUMBER}(%?),#{NUMBER}(%?)(?:,#{NUMBER}(%?))?\)\z/i
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
# SVG paint for a colour value: "crimson", "#ff0066", "none" ...
|
|
70
|
+
# Unknown colours raise a teaching error.
|
|
71
|
+
def css(value)
|
|
72
|
+
return "none" if value.nil? || value == false
|
|
73
|
+
|
|
74
|
+
name = value.to_s.strip
|
|
75
|
+
key = name.downcase.delete("_ ")
|
|
76
|
+
return key if NAMES.key?(key) || %w[none transparent currentcolor].include?(key)
|
|
77
|
+
return name if value.is_a?(String) && rgba(name)
|
|
78
|
+
|
|
79
|
+
hint = suggestion(key)
|
|
80
|
+
raise Messages.error(ArgumentError, :bad_color, got: value.inspect, hint:)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# [r, g, b, alpha] with r, g, b in 0..255 and alpha in 0..1, or nil.
|
|
84
|
+
def rgba(value)
|
|
85
|
+
text = value.to_s.strip
|
|
86
|
+
key = text.downcase.delete("_ ")
|
|
87
|
+
return [*NAMES[key], 1.0] if NAMES.key?(key)
|
|
88
|
+
return [0, 0, 0, 0.0] if key == "transparent"
|
|
89
|
+
|
|
90
|
+
if (m = HEX.match(text))
|
|
91
|
+
digits = m[1]
|
|
92
|
+
digits = digits.chars.map { _1 * 2 }.join if digits.size <= 4
|
|
93
|
+
values = digits.scan(/../).map { _1.to_i(16) }
|
|
94
|
+
return [*values[0, 3], values.size == 4 ? values[3] / 255.0 : 1.0]
|
|
95
|
+
end
|
|
96
|
+
if (m = HSL.match(text))
|
|
97
|
+
r, g, b = hsl_to_rgb(m[1].to_f, m[2].to_f, m[3].to_f)
|
|
98
|
+
return [r, g, b, alpha(m[4], m[5])]
|
|
99
|
+
end
|
|
100
|
+
if (m = RGB.match(text))
|
|
101
|
+
r, g, b = [[m[1], m[2]], [m[3], m[4]], [m[5], m[6]]].map do |number, percent|
|
|
102
|
+
(percent == "%" ? number.to_f * 2.55 : number.to_f).clamp(0, 255).round
|
|
103
|
+
end
|
|
104
|
+
return [r, g, b, alpha(m[7], m[8])]
|
|
105
|
+
end
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# [r, g, b] (0..255), or nil for "none".
|
|
110
|
+
def rgb(value) = rgba(css(value))&.first(3)
|
|
111
|
+
|
|
112
|
+
# Halfway between two colours is mix(a, b, 0.5). Answers "#rrggbb", or
|
|
113
|
+
# "rgba(...)" when either colour is see-through.
|
|
114
|
+
def mix(from, to, t)
|
|
115
|
+
a = rgba(css(from)) || raise(Messages.error(ArgumentError, :bad_color, got: from.inspect, hint: ""))
|
|
116
|
+
b = rgba(css(to)) || raise(Messages.error(ArgumentError, :bad_color, got: to.inspect, hint: ""))
|
|
117
|
+
t = t.to_f.clamp(0.0, 1.0)
|
|
118
|
+
r, g, bl, al = a.zip(b).map { |x, y| x + (y - x) * t }
|
|
119
|
+
return format("#%02x%02x%02x", r.round, g.round, bl.round) if al >= 1.0
|
|
120
|
+
|
|
121
|
+
"rgba(#{r.round}, #{g.round}, #{bl.round}, #{Asobi.fmt(al)})"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# hue in degrees (0..360), saturation and lightness in 0..100.
|
|
125
|
+
def hsl(hue, saturation = 70, lightness = 50)
|
|
126
|
+
%w[hue saturation lightness].zip([hue, saturation, lightness]).each do |name, v|
|
|
127
|
+
Check.number(v, word: :hsl, name:, example: "hsl(200, 70, 50)")
|
|
128
|
+
end
|
|
129
|
+
h = Asobi.fmt(hue % 360)
|
|
130
|
+
s = Asobi.fmt(saturation.clamp(0, 100))
|
|
131
|
+
l = Asobi.fmt(lightness.clamp(0, 100))
|
|
132
|
+
"hsl(#{h}, #{s}%, #{l}%)"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# 0 is black, 100 is white.
|
|
136
|
+
def gray(lightness = 50)
|
|
137
|
+
Check.number(lightness, word: :gray, name: :lightness, example: "gray(80)")
|
|
138
|
+
hsl(0, 0, lightness)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def hsl_to_rgb(h, s, l)
|
|
142
|
+
h = (h % 360) / 360.0
|
|
143
|
+
s = (s / 100.0).clamp(0.0, 1.0)
|
|
144
|
+
l = (l / 100.0).clamp(0.0, 1.0)
|
|
145
|
+
return [(l * 255).round] * 3 if s.zero?
|
|
146
|
+
|
|
147
|
+
q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
|
148
|
+
p = 2 * l - q
|
|
149
|
+
[h + 1 / 3.0, h, h - 1 / 3.0].map do |t|
|
|
150
|
+
t += 1 if t.negative?
|
|
151
|
+
t -= 1 if t > 1
|
|
152
|
+
v =
|
|
153
|
+
if t < 1 / 6.0 then p + (q - p) * 6 * t
|
|
154
|
+
elsif t < 1 / 2.0 then q
|
|
155
|
+
elsif t < 2 / 3.0 then p + (q - p) * (2 / 3.0 - t) * 6
|
|
156
|
+
else p
|
|
157
|
+
end
|
|
158
|
+
(v * 255).round
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def alpha(number, percent)
|
|
163
|
+
return 1.0 unless number
|
|
164
|
+
|
|
165
|
+
(percent == "%" ? number.to_f / 100 : number.to_f).clamp(0.0, 1.0)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def suggestion(key)
|
|
169
|
+
return "" if key.empty? || !defined?(DidYouMean::SpellChecker)
|
|
170
|
+
|
|
171
|
+
found = DidYouMean::SpellChecker.new(dictionary: NAMES.keys).correct(key).first
|
|
172
|
+
found ? Messages.text(:did_you_mean, name: found) : ""
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
module Asobi
|
|
178
|
+
# A gradient, used as a colour: colored(from: :gold, to: :crimson).
|
|
179
|
+
#
|
|
180
|
+
# +angle+ is in degrees, clockwise: 0 flows from top to bottom, 90 from
|
|
181
|
+
# right to left (the gradient turned a quarter clockwise). +around+ makes
|
|
182
|
+
# it flow from the centre outwards instead. Where it starts and ends is
|
|
183
|
+
# worked out when drawing, from the box of the picture it paints.
|
|
184
|
+
Gradient = Data.define(:from, :to, :angle, :around) do
|
|
185
|
+
def self.make(from, to, angle, around)
|
|
186
|
+
Asobi::Check.number(angle, word: :colored, name: :angle, example: "colored(from: :gold, to: :crimson, angle: 90)")
|
|
187
|
+
new(Asobi::Color.css(from), Asobi::Color.css(to), Asobi::Gradient.tidy(angle.to_f % 360), around ? true : false)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def self.tidy(degrees)
|
|
191
|
+
rounded = degrees.round(9)
|
|
192
|
+
rounded = 0.0 if rounded.zero? || rounded == 360.0
|
|
193
|
+
rounded
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The same gradient seen through +matrix+ (only its turning and
|
|
197
|
+
# stretching matter).
|
|
198
|
+
def through(matrix)
|
|
199
|
+
return self if around
|
|
200
|
+
|
|
201
|
+
radians = angle * Math::PI / 180
|
|
202
|
+
x, y = matrix.linear.apply(-Math.sin(radians), Math.cos(radians))
|
|
203
|
+
return self if x.abs < 1e-12 && y.abs < 1e-12
|
|
204
|
+
|
|
205
|
+
turned = Math.atan2(-x, y) * 180 / Math::PI
|
|
206
|
+
with(angle: Asobi::Gradient.tidy(turned % 360))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The <linearGradient> or <radialGradient> for a Box (in the
|
|
210
|
+
# coordinates of the picture it paints), without its id.
|
|
211
|
+
def definition(box)
|
|
212
|
+
stops = %(<stop offset="0" stop-color="#{Asobi.escape(from)}"/>) +
|
|
213
|
+
%(<stop offset="1" stop-color="#{Asobi.escape(to)}"/>)
|
|
214
|
+
box ||= Asobi::Box.new(-1.0, -1.0, 1.0, 1.0)
|
|
215
|
+
if around
|
|
216
|
+
r = [[box.width, box.height].max / 2.0, 1e-3].max
|
|
217
|
+
return %(<radialGradient gradientUnits="userSpaceOnUse" cx="#{Asobi.fmt(box.cx)}" cy="#{Asobi.fmt(box.cy)}" ) +
|
|
218
|
+
%(r="#{Asobi.fmt(r)}">#{stops}</radialGradient>)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
radians = angle * Math::PI / 180
|
|
222
|
+
dx = Asobi::Affine.tidy(-Math.sin(radians))
|
|
223
|
+
dy = Asobi::Affine.tidy(Math.cos(radians))
|
|
224
|
+
half = (dx.abs * box.width + dy.abs * box.height) / 2.0
|
|
225
|
+
if half < 1e-6 # a flat line across the flow: let it flow along the line
|
|
226
|
+
dx, dy = box.width >= box.height ? [1.0, 0.0] : [0.0, 1.0]
|
|
227
|
+
half = [box.width, box.height].max / 2.0
|
|
228
|
+
half = 1.0 if half < 1e-6
|
|
229
|
+
end
|
|
230
|
+
x1, y1 = box.cx - dx * half, box.cy - dy * half
|
|
231
|
+
x2, y2 = box.cx + dx * half, box.cy + dy * half
|
|
232
|
+
%(<linearGradient gradientUnits="userSpaceOnUse" x1="#{Asobi.fmt(x1)}" y1="#{Asobi.fmt(y1)}" ) +
|
|
233
|
+
%(x2="#{Asobi.fmt(x2)}" y2="#{Asobi.fmt(y2)}">#{stops}</linearGradient>)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pure"
|
|
4
|
+
|
|
5
|
+
# to_art is the new to_s.
|
|
6
|
+
#
|
|
7
|
+
# Ruby objects already know how to become a String (to_s), an Array (to_a),
|
|
8
|
+
# a Proc (to_proc). Here they learn to become a picture. The rules live in
|
|
9
|
+
# Asobi::Convert; these methods only open the door.
|
|
10
|
+
module Asobi
|
|
11
|
+
module CoreExt
|
|
12
|
+
module ToArt
|
|
13
|
+
# 8.to_art, [3, 1, 4].to_art(as: star)
|
|
14
|
+
def to_art(as: nil) = Asobi.art(self, as:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Turn
|
|
18
|
+
# Angles in turns: 0.25.turn is 90.0 degrees.
|
|
19
|
+
def turn = self * 360.0
|
|
20
|
+
alias_method :turns, :turn
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
[Integer, Float, Rational, Array, Range, Hash, String, Symbol, NilClass].each do |klass|
|
|
26
|
+
klass.include(Asobi::CoreExt::ToArt)
|
|
27
|
+
end
|
|
28
|
+
Numeric.include(Asobi::CoreExt::Turn)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asobi
|
|
4
|
+
# 2D affine transform: x' = a*x + c*y + e, y' = b*x + d*y + f
|
|
5
|
+
#
|
|
6
|
+
# Coordinates are SVG's: x to the right, y downwards. With y pointing down
|
|
7
|
+
# the usual rotation matrix turns things clockwise, which is what
|
|
8
|
+
# rotate(45) promises.
|
|
9
|
+
Affine = Data.define(:a, :b, :c, :d, :e, :f) do
|
|
10
|
+
def self.translate(x, y) = new(1.0, 0.0, 0.0, 1.0, x.to_f, y.to_f)
|
|
11
|
+
def self.scale(sx, sy = sx) = new(sx.to_f, 0.0, 0.0, sy.to_f, 0.0, 0.0)
|
|
12
|
+
|
|
13
|
+
# Clockwise, in degrees. Quarter turns are exact, so rotate(90) four
|
|
14
|
+
# times gives back exactly the same picture.
|
|
15
|
+
def self.rotate(degrees)
|
|
16
|
+
radians = degrees * Math::PI / 180
|
|
17
|
+
cos = Asobi::Affine.tidy(Math.cos(radians))
|
|
18
|
+
sin = Asobi::Affine.tidy(Math.sin(radians))
|
|
19
|
+
new(cos, sin, -sin, cos, 0.0, 0.0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.tidy(value) = value.abs < 1e-12 ? 0.0 : value
|
|
23
|
+
|
|
24
|
+
# Composition: (self * other) applies +other+ first, then +self+.
|
|
25
|
+
def *(other)
|
|
26
|
+
Affine.new(
|
|
27
|
+
a * other.a + c * other.b,
|
|
28
|
+
b * other.a + d * other.b,
|
|
29
|
+
a * other.c + c * other.d,
|
|
30
|
+
b * other.c + d * other.d,
|
|
31
|
+
a * other.e + c * other.f + e,
|
|
32
|
+
b * other.e + d * other.f + f
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def apply(x, y) = [a * x + c * y + e, b * x + d * y + f]
|
|
37
|
+
|
|
38
|
+
# Only the turning and stretching part (no moving): for directions.
|
|
39
|
+
def linear = Affine.new(a, b, c, d, 0.0, 0.0)
|
|
40
|
+
|
|
41
|
+
# The linear part undone (the identity when it can't be undone).
|
|
42
|
+
def inverse_linear
|
|
43
|
+
det = a * d - b * c
|
|
44
|
+
return IDENTITY if det.abs < 1e-12
|
|
45
|
+
|
|
46
|
+
Affine.new(d / det, -b / det, -c / det, a / det, 0.0, 0.0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# How much this transform stretches lengths, on average (for strokes).
|
|
50
|
+
def stretch = Math.sqrt((a * d - b * c).abs)
|
|
51
|
+
|
|
52
|
+
def identity? = self == IDENTITY
|
|
53
|
+
def translation? = a == 1.0 && b.zero? && c.zero? && d == 1.0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
IDENTITY = Affine.new(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
|
57
|
+
|
|
58
|
+
# Axis-aligned bounding box, in picture coordinates (y down).
|
|
59
|
+
Box = Data.define(:x0, :y0, :x1, :y1) do
|
|
60
|
+
def self.around(points)
|
|
61
|
+
xs = points.map(&:first)
|
|
62
|
+
ys = points.map(&:last)
|
|
63
|
+
new(xs.min.to_f, ys.min.to_f, xs.max.to_f, ys.max.to_f)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The box around a rectangle w x h centred on the origin, transformed.
|
|
67
|
+
def self.of_rect(w, h, m)
|
|
68
|
+
x = w / 2.0
|
|
69
|
+
y = h / 2.0
|
|
70
|
+
around([[-x, -y], [x, -y], [x, y], [-x, y]].map { m.apply(*_1) })
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.union(boxes)
|
|
74
|
+
boxes = boxes.compact
|
|
75
|
+
return nil if boxes.empty?
|
|
76
|
+
|
|
77
|
+
new(boxes.map(&:x0).min, boxes.map(&:y0).min, boxes.map(&:x1).max, boxes.map(&:y1).max)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def width = x1 - x0
|
|
81
|
+
def height = y1 - y0
|
|
82
|
+
def cx = (x0 + x1) / 2.0
|
|
83
|
+
def cy = (y0 + y1) / 2.0
|
|
84
|
+
def grow(n) = Box.new(x0 - n, y0 - n, x1 + n, y1 + n)
|
|
85
|
+
|
|
86
|
+
# Strictly overlapping (touching edges do not count).
|
|
87
|
+
def overlap?(other) = x0 < other.x1 && other.x0 < x1 && y0 < other.y1 && other.y0 < y1
|
|
88
|
+
|
|
89
|
+
# [x, y, width, height], the order SVG's viewBox uses.
|
|
90
|
+
def to_a = [x0, y0, width, height]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
module Geometry
|
|
94
|
+
module_function
|
|
95
|
+
|
|
96
|
+
# The extent of a cubic Bézier along one axis: its end points plus any
|
|
97
|
+
# turning points in between. p0..p3 are numbers (one coordinate).
|
|
98
|
+
def cubic_extent(p0, p1, p2, p3)
|
|
99
|
+
values = [p0, p3]
|
|
100
|
+
# Derivative / 3 is a quadratic qa t^2 + qb t + qc
|
|
101
|
+
qa = -p0 + 3 * p1 - 3 * p2 + p3
|
|
102
|
+
qb = 2 * (p0 - 2 * p1 + p2)
|
|
103
|
+
qc = p1 - p0
|
|
104
|
+
roots =
|
|
105
|
+
if qa.abs < 1e-12
|
|
106
|
+
qb.abs < 1e-12 ? [] : [-qc / qb]
|
|
107
|
+
else
|
|
108
|
+
disc = qb * qb - 4 * qa * qc
|
|
109
|
+
disc.negative? ? [] : [(-qb + Math.sqrt(disc)) / (2 * qa), (-qb - Math.sqrt(disc)) / (2 * qa)]
|
|
110
|
+
end
|
|
111
|
+
roots.each do |t|
|
|
112
|
+
next unless t > 0 && t < 1
|
|
113
|
+
|
|
114
|
+
u = 1 - t
|
|
115
|
+
values << u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
|
|
116
|
+
end
|
|
117
|
+
values.minmax
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A point at +distance+ from the origin, +degrees+ clockwise from straight up.
|
|
121
|
+
def polar(distance, degrees)
|
|
122
|
+
radians = degrees * Math::PI / 180
|
|
123
|
+
[Affine.tidy(distance * Math.sin(radians)), Affine.tidy(-distance * Math.cos(radians))]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/asobi/live.rb
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require_relative "sketch"
|
|
7
|
+
|
|
8
|
+
module Asobi
|
|
9
|
+
Say.add(:live_ready,
|
|
10
|
+
en: "asobi: %{file} is playing at %{url} - save the file to change the picture, Ctrl-C to stop",
|
|
11
|
+
ja: "asobi: %{file} を %{url} に表示しています。ファイルを保存すると絵が変わります。止めるには Ctrl-C")
|
|
12
|
+
Say.add(:live_ready_irb,
|
|
13
|
+
en: "asobi: playing at %{url} - every show changes the picture",
|
|
14
|
+
ja: "asobi: %{url} に表示しています。show するたびに絵が変わります")
|
|
15
|
+
Say.add(:live_bye, en: "asobi: bye!", ja: "asobi: またね")
|
|
16
|
+
Messages.add(:live_no_port,
|
|
17
|
+
en: "asobi couldn't find a free port from %{from} to %{to}. Close the other asobi, or pick one, like: asobi --port 5000",
|
|
18
|
+
ja: "%{from} から %{to} までの空いているポートが見つかりません。ほかの asobi を閉じるか、ポートを選んでください。例:asobi --port 5000")
|
|
19
|
+
Messages.add(:live_invalid_reel,
|
|
20
|
+
en: 'asobi could not send this sketch to the browser. Use UTF-8 for the file and its text, and finite numbers for drawing, like: show text("hello", size: 24)',
|
|
21
|
+
ja: 'このスケッチをブラウザに送れませんでした。ファイルと文字列には UTF-8 を使い、描画には有限の数を指定してください。例:show text("こんにちは", size: 24)')
|
|
22
|
+
|
|
23
|
+
# The live view: a tiny HTTP server (standard library only) that serves the
|
|
24
|
+
# player page and streams Reels to every open browser tab (Server-Sent
|
|
25
|
+
# Events). This is the only file that uses sockets and threads.
|
|
26
|
+
module Live
|
|
27
|
+
HOST = "127.0.0.1"
|
|
28
|
+
DEFAULT_PORT = 4567
|
|
29
|
+
PORT_TRIES = 20
|
|
30
|
+
HEARTBEAT = 15 # seconds between keep-alive comments
|
|
31
|
+
POLL = 0.2 # seconds between looks at the sketch file
|
|
32
|
+
PAGE = File.join(__dir__, "web", "live.html")
|
|
33
|
+
PLACEHOLDER = "/*ASOBI_PLAYER_JS*/"
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# Blocking. Evaluate the sketch, serve it, and follow the file until
|
|
38
|
+
# Ctrl-C (or until the server is stopped). Yields the server once it is
|
|
39
|
+
# ready. +initial+ is a Reel already made from this run of the script.
|
|
40
|
+
def run(path, port: DEFAULT_PORT, open: true, initial: nil, out: $stdout)
|
|
41
|
+
path = File.expand_path(path.to_s)
|
|
42
|
+
server = Server.new(port:)
|
|
43
|
+
seen = stamp(path)
|
|
44
|
+
server.push(initial || Sketch.evaluate(path))
|
|
45
|
+
out.puts Say.text(:live_ready, file: File.basename(path), url: server.url)
|
|
46
|
+
out.flush
|
|
47
|
+
open_browser(server.url) if open
|
|
48
|
+
yield server if block_given?
|
|
49
|
+
watch(path, server, seen)
|
|
50
|
+
rescue Interrupt
|
|
51
|
+
out.puts
|
|
52
|
+
out.puts Say.text(:live_bye)
|
|
53
|
+
ensure
|
|
54
|
+
server&.stop
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Non-blocking, for irb: the server object (push, stop, url).
|
|
58
|
+
def start(port: DEFAULT_PORT, open: true, announce: false, out: $stdout)
|
|
59
|
+
server = Server.new(port:)
|
|
60
|
+
if announce
|
|
61
|
+
out.puts Say.text(:live_ready_irb, url: server.url)
|
|
62
|
+
out.flush
|
|
63
|
+
end
|
|
64
|
+
open_browser(server.url) if open
|
|
65
|
+
server
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Poll the file; when it changes (and has settled), evaluate and push.
|
|
69
|
+
def watch(path, server, seen = stamp(path))
|
|
70
|
+
until server.stopped?
|
|
71
|
+
sleep POLL
|
|
72
|
+
now = stamp(path)
|
|
73
|
+
next if now.nil? || now == seen
|
|
74
|
+
|
|
75
|
+
loop do # wait until the editor has finished writing
|
|
76
|
+
sleep 0.05
|
|
77
|
+
later = stamp(path)
|
|
78
|
+
break if later == now || later.nil?
|
|
79
|
+
|
|
80
|
+
now = later
|
|
81
|
+
end
|
|
82
|
+
seen = now
|
|
83
|
+
server.push(Sketch.evaluate(path))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def stamp(path)
|
|
88
|
+
stat = File.stat(path)
|
|
89
|
+
[stat.mtime, stat.size]
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
nil # briefly missing while an editor saves
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def open_browser(url)
|
|
95
|
+
return if ENV["ASOBI_BROWSER"] == "0"
|
|
96
|
+
|
|
97
|
+
command =
|
|
98
|
+
case RbConfig::CONFIG["host_os"]
|
|
99
|
+
when /darwin/ then ["open", url]
|
|
100
|
+
when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", "", url]
|
|
101
|
+
else ["xdg-open", url]
|
|
102
|
+
end
|
|
103
|
+
pid = Process.spawn(*command, out: File::NULL, err: File::NULL)
|
|
104
|
+
Process.detach(pid)
|
|
105
|
+
rescue SystemCallError
|
|
106
|
+
nil # no browser to open; the URL was printed
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def page
|
|
110
|
+
@page ||= File.read(PAGE, encoding: "UTF-8").sub(PLACEHOLDER) { Reel.player_js }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class Server
|
|
114
|
+
attr_reader :port
|
|
115
|
+
|
|
116
|
+
def initialize(port: DEFAULT_PORT)
|
|
117
|
+
@mutex = Mutex.new
|
|
118
|
+
@clients = []
|
|
119
|
+
@reel = nil
|
|
120
|
+
@good = nil
|
|
121
|
+
@stopped = false
|
|
122
|
+
@listener = listen(port)
|
|
123
|
+
@port = @listener.addr[1]
|
|
124
|
+
@thread = Thread.new { accept_loop }
|
|
125
|
+
@thread.report_on_exception = false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def url = "http://#{HOST}:#{port}/"
|
|
129
|
+
def stopped? = @stopped
|
|
130
|
+
|
|
131
|
+
# Show this Reel (a Hash) in every open tab, now and for new tabs.
|
|
132
|
+
def push(reel)
|
|
133
|
+
begin
|
|
134
|
+
json = JSON.generate(reel)
|
|
135
|
+
rescue JSON::GeneratorError
|
|
136
|
+
code = reel["code"]&.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
137
|
+
reel = Reel.error(Messages.text(:live_invalid_reel), code:)
|
|
138
|
+
json = JSON.generate(reel)
|
|
139
|
+
end
|
|
140
|
+
@mutex.synchronize do
|
|
141
|
+
@reel = json
|
|
142
|
+
@good = json unless reel["error"]
|
|
143
|
+
@clients.each { _1.queue << json }
|
|
144
|
+
end
|
|
145
|
+
reel
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def stop
|
|
149
|
+
return if @stopped
|
|
150
|
+
|
|
151
|
+
@stopped = true
|
|
152
|
+
@listener.close unless @listener.closed?
|
|
153
|
+
@thread.kill
|
|
154
|
+
@mutex.synchronize { @clients.each { _1.queue << :stop } }
|
|
155
|
+
rescue IOError, SystemCallError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
Client = Struct.new(:socket, :queue)
|
|
162
|
+
|
|
163
|
+
def listen(port)
|
|
164
|
+
return TCPServer.new(HOST, port) if port.zero?
|
|
165
|
+
|
|
166
|
+
port.upto(port + PORT_TRIES) do |candidate|
|
|
167
|
+
next if taken?(candidate)
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
return TCPServer.new(HOST, candidate)
|
|
171
|
+
rescue Errno::EADDRINUSE, Errno::EACCES
|
|
172
|
+
next
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
raise Messages.error(IOError, :live_no_port, from: port, to: port + PORT_TRIES)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Something already answers there (even when binding would not fail).
|
|
179
|
+
def taken?(port)
|
|
180
|
+
Socket.tcp(HOST, port, connect_timeout: 0.3) { true }
|
|
181
|
+
rescue SystemCallError, IOError
|
|
182
|
+
false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def accept_loop
|
|
186
|
+
loop do
|
|
187
|
+
socket = @listener.accept
|
|
188
|
+
thread = Thread.new(socket) { |s| serve(s) }
|
|
189
|
+
thread.report_on_exception = false
|
|
190
|
+
end
|
|
191
|
+
rescue IOError, SystemCallError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def serve(socket)
|
|
196
|
+
request = read_request(socket)
|
|
197
|
+
case request
|
|
198
|
+
when %r{\AGET / }, %r{\AGET /\?\S* } then respond(socket, "200 OK", "text/html; charset=utf-8", Live.page)
|
|
199
|
+
when %r{\AGET /events[ ?]} then stream(socket)
|
|
200
|
+
else respond(socket, "404 Not Found", "text/plain; charset=utf-8", "not found\n")
|
|
201
|
+
end
|
|
202
|
+
rescue IOError, SystemCallError
|
|
203
|
+
nil
|
|
204
|
+
ensure
|
|
205
|
+
socket.close unless socket.closed?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The request line; the headers are read and dropped.
|
|
209
|
+
def read_request(socket)
|
|
210
|
+
return nil unless IO.select([socket], nil, nil, 5)
|
|
211
|
+
|
|
212
|
+
first = socket.gets("\n", 4096).to_s
|
|
213
|
+
32.times do
|
|
214
|
+
break unless IO.select([socket], nil, nil, 2)
|
|
215
|
+
|
|
216
|
+
line = socket.gets("\n", 8192)
|
|
217
|
+
break if line.nil? || line.strip.empty?
|
|
218
|
+
end
|
|
219
|
+
first
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def respond(socket, status, type, body)
|
|
223
|
+
body = body.b
|
|
224
|
+
socket.write "HTTP/1.1 #{status}\r\nContent-Type: #{type}\r\nContent-Length: #{body.bytesize}\r\n" \
|
|
225
|
+
"Cache-Control: no-store\r\nConnection: close\r\n\r\n"
|
|
226
|
+
socket.write body
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def stream(socket)
|
|
230
|
+
socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
231
|
+
socket.write "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\n" \
|
|
232
|
+
"Cache-Control: no-store\r\nConnection: keep-alive\r\n\r\nretry: 1000\n\n"
|
|
233
|
+
client = Client.new(socket, Queue.new)
|
|
234
|
+
@mutex.synchronize do
|
|
235
|
+
return if @stopped
|
|
236
|
+
|
|
237
|
+
client.queue << @good if @good && @good != @reel
|
|
238
|
+
client.queue << @reel if @reel
|
|
239
|
+
@clients << client
|
|
240
|
+
end
|
|
241
|
+
pump(client)
|
|
242
|
+
ensure
|
|
243
|
+
@mutex.synchronize { @clients.delete(client) } if client
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def pump(client)
|
|
247
|
+
loop do
|
|
248
|
+
item = client.queue.pop(timeout: HEARTBEAT)
|
|
249
|
+
return if item == :stop
|
|
250
|
+
|
|
251
|
+
client.socket.write(item ? "data: #{item}\n\n" : ": keep-alive\n\n")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|