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/pure.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The core of asobi, without touching Ruby itself: no to_art on Integer,
4
+ # no shape words on Object. Write Asobi.circle(...) and Asobi.art(8).
5
+ require_relative "version"
6
+ require_relative "messages"
7
+ require_relative "geometry"
8
+ require_relative "color"
9
+ require_relative "random"
10
+ require_relative "shapes"
11
+ require_relative "art"
12
+ require_relative "nodes"
13
+ require_relative "many"
14
+ require_relative "svg"
15
+
16
+ # Parts that live in their own files: pictures in motion, and the reel a
17
+ # browser plays. Neither touches Ruby's own classes.
18
+ %w[motion reel].each do |part|
19
+ path = File.join(__dir__, "#{part}.rb")
20
+ require path if File.exist?(path)
21
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # Randomness with a memory.
5
+ #
6
+ # Without seed:, every run of a sketch looks different. But within one run
7
+ # the picture stays put: the live view re-evaluates the sketch on every
8
+ # save, and the dots must not jump around each time.
9
+ #
10
+ # So one "session seed" is picked when the program starts. Each evaluation
11
+ # of a sketch begins with reset_calls!, and the n-th seedless random word
12
+ # in that evaluation gets a seed made from (session seed, n).
13
+ module Random
14
+ MASK = (1 << 64) - 1
15
+ MIX = 0x9E3779B97F4A7C15 # odd constant, spreads consecutive counts apart
16
+
17
+ @session_seed = ::Random.new_seed & MASK
18
+ @calls = 0
19
+
20
+ class << self
21
+ attr_reader :session_seed, :calls
22
+
23
+ # Fix the session seed (for tests, or to reproduce a run).
24
+ def session_seed=(seed)
25
+ @session_seed = Integer(seed) & MASK
26
+ @calls = 0
27
+ end
28
+
29
+ # Start counting from zero again; called before each sketch evaluation.
30
+ def reset_calls!
31
+ @calls = 0
32
+ end
33
+
34
+ # The seed for the next seedless call.
35
+ def next_seed
36
+ seed = (@session_seed ^ ((@calls + 1) * MIX)) & MASK
37
+ @calls += 1
38
+ seed
39
+ end
40
+
41
+ # A generator: from the explicit seed when there is one, otherwise
42
+ # from the session seed and the call count.
43
+ def generator(seed = nil)
44
+ return ::Random.new(next_seed) if seed.nil?
45
+ raise Messages.error(ArgumentError, :bad_seed, got: seed.inspect) unless seed.is_a?(Integer)
46
+
47
+ ::Random.new(seed)
48
+ end
49
+ end
50
+ end
51
+ end
data/lib/asobi/reel.rb ADDED
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Asobi
6
+ # The reel is what the browser plays (DESIGN.md section 9): a list of SVG
7
+ # fragments, how fast to play them, the viewBox, the background colour and
8
+ # (for the live view) the code and any error.
9
+ #
10
+ # Pure Ruby on purpose: no sockets, threads or file watching, so it also
11
+ # runs inside ruby.wasm.
12
+ module Reel
13
+ MAX_FRAMES = 900
14
+ PLAYER = File.join(__dir__, "web", "player.js")
15
+
16
+ module_function
17
+
18
+ # A Hash that JSON.generate turns into the Reel contract.
19
+ #
20
+ # Reel.of(flower) one frame, fps 1
21
+ # Reel.of(flower.spin(every: 4), fps: 30) 120 frames
22
+ # Reel.of(flower, grain: true) fine grain on every frame
23
+ #
24
+ # Grain is a small constant fragment (a noise filter and one rectangle
25
+ # over the view) added to the end of every frame, so the player needs
26
+ # nothing new and the grain stays still while the picture moves.
27
+ def of(thing, fps: 30, on: :white, paper: nil, code: nil, grain: nil)
28
+ background = Color.css(on)
29
+ strength = SVG.grain_strength(grain)
30
+ if motion?(thing)
31
+ frames, fps = motion_frames(thing, fps)
32
+ boxes = frames.map { |art| art.box(IDENTITY) }
33
+ svgs = frames.map(&:svg)
34
+ box = Box.union(boxes)
35
+ else
36
+ art = Asobi.art(thing)
37
+ svgs = [art.svg]
38
+ fps = 1
39
+ box = art.box(IDENTITY)
40
+ end
41
+ view = SVG.view(box, paper:).map { _1.to_f.round(3) }
42
+ if strength
43
+ noise = SVG.grain(view, strength)
44
+ svgs = svgs.map { _1 + noise }
45
+ end
46
+ { "frames" => svgs, "fps" => fps, "view" => view, "background" => background, "code" => code, "error" => nil }
47
+ end
48
+
49
+ # What the screen shows when the sketch is broken.
50
+ def error(message, code: nil) = { "error" => message.to_s, "code" => code }
51
+
52
+ # A complete page that plays the reel by itself, full window.
53
+ def html(thing, title: "asobi", **options)
54
+ reel = thing.is_a?(Hash) && thing.key?("frames") ? thing : of(thing, **options)
55
+ page(reel, title)
56
+ end
57
+
58
+ def save(thing, path, **options)
59
+ File.write(path.to_s, html(thing, **options), encoding: "UTF-8")
60
+ path
61
+ end
62
+
63
+ # The player program, safe to put between <script> tags.
64
+ def player_js = File.read(PLAYER, encoding: "UTF-8").gsub("</script", "<\\/script")
65
+
66
+ # JSON that can sit inside a <script> tag: "<" never appears raw.
67
+ def script_json(data) = JSON.generate(data).gsub("<", "\\u003c")
68
+
69
+ def motion?(thing) = thing.respond_to?(:at) && thing.respond_to?(:every)
70
+
71
+ # One loop of a motion: [pictures, fps]. The pictures are spread evenly
72
+ # over exactly one loop (frame i is at every * i / count seconds, as in
73
+ # Motion#frames), so the last frame leads straight back to the first.
74
+ # The fps reported is count / every: an Integer when it is whole, else a
75
+ # Float. Never more than MAX_FRAMES; a longer loop gets a lower fps.
76
+ def motion_frames(motion, fps)
77
+ fps = Check.whole(fps, word: :save, name: :fps, example: "save(\"flower.html\", fps: 30)", min: 1)
78
+ every = Check.number(motion.every, word: :every, name: :every, example: "spin(every: 4)", min: 0)
79
+ return [[Asobi.art(motion.at(0))], 1] unless every.positive?
80
+
81
+ count = ((every * fps).round).clamp(1, MAX_FRAMES)
82
+ frames = Array.new(count) { |i| Asobi.art(motion.at(every * i / count)) }
83
+ rate = count / every
84
+ [frames, (rate - rate.round).abs < 1e-9 ? rate.round : rate]
85
+ end
86
+
87
+ def page(reel, title)
88
+ <<~HTML
89
+ <!doctype html>
90
+ <html>
91
+ <head>
92
+ <meta charset="utf-8">
93
+ <meta name="viewport" content="width=device-width, initial-scale=1">
94
+ <title>#{Asobi.escape(title)}</title>
95
+ <style>
96
+ html, body { margin: 0; height: 100%; overflow: hidden; }
97
+ #stage { position: fixed; inset: 0; }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <div id="stage"></div>
102
+ <script>#{player_js}</script>
103
+ <script>
104
+ AsobiPlayer.mount(document.getElementById("stage")).update(#{script_json(reel)});
105
+ </script>
106
+ </body>
107
+ </html>
108
+ HTML
109
+ end
110
+ end
111
+
112
+ Art::SAVERS[".html"] = ->(art, path, **options) { Reel.save(art, path, **options) }
113
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # The words that make shapes. Each answers a picture centred on the
5
+ # origin; none of them draws anything. Every number may be left out, and
6
+ # the first one may be given without its name:
7
+ #
8
+ # circle radius 10
9
+ # circle(radius: 12) or, once you know it, circle(12)
10
+ #
11
+ # With require "asobi" they work everywhere, like puts. With
12
+ # require "asobi/pure" write Asobi.circle(...).
13
+ module Shapes
14
+ def circle(radius_ = nil, radius: radius_ || 10)
15
+ Circle.new(Shapes.size(radius, :circle, :radius, "circle(radius: 10)"))
16
+ end
17
+
18
+ def ellipse(width_ = nil, height_ = nil, width: width_ || 30, height: height_ || 20)
19
+ w = Shapes.size(width, :ellipse, :width, "ellipse(width: 30, height: 20)")
20
+ h = Shapes.size(height, :ellipse, :height, "ellipse(width: 30, height: 20)")
21
+ Ellipse.new(w / 2.0, h / 2.0)
22
+ end
23
+
24
+ def rect(width_ = nil, height_ = nil, width: width_ || 30, height: height_ || 20)
25
+ w = Shapes.size(width, :rect, :width, "rect(width: 30, height: 20)")
26
+ h = Shapes.size(height, :rect, :height, "rect(width: 30, height: 20)")
27
+ Rect.new(w, h)
28
+ end
29
+
30
+ def square(size_ = nil, size: size_ || 20)
31
+ s = Shapes.size(size, :square, :size, "square(size: 20)")
32
+ Rect.new(s, s)
33
+ end
34
+
35
+ # Equilateral, pointing up; +size+ is the length of a side. Centred on
36
+ # its bounding box, so it lines up with other shapes.
37
+ def triangle(size_ = nil, size: size_ || 20)
38
+ s = Shapes.size(size, :triangle, :size, "triangle(size: 20)")
39
+ h = s * Math.sqrt(3) / 2
40
+ Path.polyline([[0.0, -h / 2], [s / 2.0, h / 2], [-s / 2.0, h / 2]], closed: true)
41
+ end
42
+
43
+ # +sides+ equal sides, one corner pointing straight up.
44
+ def polygon(sides_ = nil, radius_ = nil, sides: sides_ || 6, radius: radius_ || 10)
45
+ n = Check.whole(sides, word: :polygon, name: :sides, example: "polygon(sides: 6)", min: 3)
46
+ r = Shapes.size(radius, :polygon, :radius, "polygon(sides: 6, radius: 10)")
47
+ Path.polyline(Array.new(n) { |i| Geometry.polar(r, i * 360.0 / n) }, closed: true)
48
+ end
49
+
50
+ def star(points_ = nil, radius_ = nil, points: points_ || 5, radius: radius_ || 10, inner: nil)
51
+ n = Check.whole(points, word: :star, name: :points, example: "star(points: 5)", min: 2)
52
+ r = Shapes.size(radius, :star, :radius, "star(points: 5, radius: 10)")
53
+ inner = Shapes.size(inner || r * 0.45, :star, :inner, "star(radius: 10, inner: 4)")
54
+ corners = Array.new(n * 2) { |i| Geometry.polar(i.even? ? r : inner, i * 180.0 / n) }
55
+ Path.polyline(corners, closed: true)
56
+ end
57
+
58
+ # A straight black line lying flat.
59
+ def line(length_ = nil, length: length_ || 20)
60
+ l = Shapes.size(length, :line, :length, "line(length: 20)")
61
+ Path.polyline([[-l / 2, 0.0], [l / 2, 0.0]]).outlined(:black, width: 1)
62
+ end
63
+
64
+ def text(string = "asobi", size: 16, font: nil)
65
+ Text.new(string.to_s, Shapes.size(size, :text, :size, "text(\"ruby\", size: 16)"), font)
66
+ end
67
+
68
+ # Joins the points in order, centred on the origin.
69
+ # path([0, 0], [40, -30], [80, 0]) an open line
70
+ # path([0, 0], [40, -30], [80, 0], closed: true) a filled shape
71
+ # path(..., smooth: true) a smooth curve
72
+ def path(*points, closed: false, smooth: false)
73
+ points = points.first if points.size == 1 && points.first.is_a?(Array) && points.first.first.is_a?(Array)
74
+ points = [[0, 0], [40, -30], [80, 0]] if points.empty?
75
+ points = Shapes.points(points)
76
+ cx = (points.map(&:first).minmax.sum) / 2.0
77
+ cy = (points.map(&:last).minmax.sum) / 2.0
78
+ points = points.map { |x, y| [x - cx, y - cy] }
79
+ shape = smooth ? Path.smooth(points, closed:) : Path.polyline(points, closed:)
80
+ closed ? shape : shape.outlined(:black, width: 1)
81
+ end
82
+
83
+ # A piece of a circle, drawn as a line. Angles are in degrees: 0 is
84
+ # straight up, and they grow clockwise, so 0 to 180 is the right half.
85
+ # Centred on the middle of its circle, so arcs of one circle line up.
86
+ # arc the right half of a circle
87
+ # arc(30, from: 90, to: 360) three quarters
88
+ # arc(from: 0, to: 360) a whole circle (as a line)
89
+ def arc(radius_ = nil, radius: radius_ || 20, from: 0, to: 180)
90
+ r = Shapes.size(radius, :arc, :radius, "arc(radius: 20, from: 0, to: 180)")
91
+ a = Check.number(from, word: :arc, name: :from, example: "arc(from: 0, to: 180)")
92
+ b = Check.number(to, word: :arc, name: :to, example: "arc(from: 0, to: 180)")
93
+ Path.build do |pen|
94
+ pen.move(*Geometry.polar(r, a))
95
+ Shapes.arc_to(pen, 0.0, 0.0, r, a, b)
96
+ end.outlined(:black, width: 1)
97
+ end
98
+
99
+ # A smooth line lying flat: +waves+ full ups and downs across +length+,
100
+ # +height+ from the top of a crest to the bottom of a trough.
101
+ def wave(length_ = nil, height_ = nil, waves_ = nil,
102
+ length: length_ || 100, height: height_ || 10, waves: waves_ || 3)
103
+ example = "wave(length: 100, height: 10, waves: 3)"
104
+ l = Shapes.size(length, :wave, :length, example)
105
+ h = Shapes.size(height, :wave, :height, example)
106
+ w = Check.number(waves, word: :wave, name: :waves, example:, min: 0)
107
+ halves = [(w * 2).round, 1].max
108
+ step = l / halves
109
+ amp = h / 2.0
110
+ Path.build do |pen|
111
+ pen.move(-l / 2, 0.0)
112
+ halves.times do |i|
113
+ x0 = -l / 2 + i * step
114
+ y = (i.even? ? -1 : 1) * amp * 4.0 / 3
115
+ pen.curve(x0 + step * 0.4244, y, x0 + step * 0.5756, y, x0 + step, 0.0)
116
+ end
117
+ end.outlined(:black, width: 1)
118
+ end
119
+
120
+ # A doughnut; the hole really is a hole, so what is behind shows through.
121
+ def ring(radius_ = nil, thickness_ = nil, radius: radius_ || 20, thickness: thickness_ || 4)
122
+ example = "ring(radius: 20, thickness: 4)"
123
+ r = Shapes.size(radius, :ring, :radius, example)
124
+ t = Shapes.size(thickness, :ring, :thickness, example)
125
+ return Circle.new(r) if t >= r
126
+
127
+ Path.build(fill_rule: :evenodd) do |pen|
128
+ [r, r - t].each do |radius_here|
129
+ pen.move(0.0, -radius_here)
130
+ Shapes.arc_to(pen, 0.0, 0.0, radius_here, 0, 360)
131
+ pen.close
132
+ end
133
+ end
134
+ end
135
+
136
+ # +size+ is the width of the whole heart.
137
+ def heart(size_ = nil, size: size_ || 20)
138
+ s = Shapes.size(size, :heart, :size, "heart(size: 20)")
139
+ half = [
140
+ [0.0, 0.95], [-0.2, 0.75, -1.0, 0.25, -1.0, -0.28],
141
+ [-1.0, -0.72, -0.68, -0.95, -0.38, -0.95],
142
+ [-0.18, -0.95, -0.05, -0.82, 0.0, -0.62]
143
+ ]
144
+ Shapes.fit(Shapes.mirrored(half), width: s)
145
+ end
146
+
147
+ # A leaf that starts at the origin and grows straight up, so
148
+ # petal.repeat(8).in_fan is already a flower.
149
+ def petal(length_ = nil, width_ = nil, length: length_ || 20, width: width_ || 10)
150
+ example = "petal(length: 20, width: 10)"
151
+ l = Shapes.size(length, :petal, :length, example)
152
+ w = Shapes.size(width, :petal, :width, example)
153
+ raw = Path.build do |pen|
154
+ pen.move(0.0, 0.0)
155
+ pen.curve(-1.0, -0.15, -0.8, -0.75, 0.0, -1.0)
156
+ pen.curve(0.8, -0.75, 1.0, -0.15, 0.0, 0.0)
157
+ pen.close
158
+ end
159
+ box = raw.box(IDENTITY)
160
+ Shapes.map_path(raw) { |x, y| [x * w / box.width, y * l] }
161
+ end
162
+
163
+ # A crescent opening to the right; +size+ is its height.
164
+ def moon(size_ = nil, size: size_ || 20)
165
+ s = Shapes.size(size, :moon, :size, "moon(size: 20)")
166
+ d = 0.45
167
+ inner = 0.8
168
+ x = (d * d + 1 - inner * inner) / (2 * d) # where the two circles cross
169
+ tip = Math.asin(x) * 180 / Math::PI
170
+ back = Math.asin((x - d) / inner) * 180 / Math::PI
171
+ raw = Path.build do |pen|
172
+ pen.move(*Geometry.polar(1.0, tip))
173
+ Shapes.arc_to(pen, 0.0, 0.0, 1.0, tip, -180 - tip)
174
+ Shapes.arc_to(pen, d, 0.0, inner, 180 - back, 360 + back)
175
+ pen.close
176
+ end
177
+ Shapes.fit(raw.commands, height: s)
178
+ end
179
+
180
+ # A water drop, point up; +size+ is its height.
181
+ def drop(size_ = nil, size: size_ || 20)
182
+ s = Shapes.size(size, :drop, :size, "drop(size: 20)")
183
+ raw = Path.build do |pen|
184
+ pen.move(0.0, -1.0)
185
+ pen.curve(0.1, -0.65, 0.7, -0.3, 0.7, 0.3)
186
+ Shapes.arc_to(pen, 0.0, 0.3, 0.7, 90, 270)
187
+ pen.curve(-0.7, -0.3, -0.1, -0.65, 0.0, -1.0)
188
+ pen.close
189
+ end
190
+ Shapes.fit(raw.commands, height: s)
191
+ end
192
+
193
+ # One outline of soft bumps over a flat bottom; +size+ is its width.
194
+ def cloud(size_ = nil, size: size_ || 30)
195
+ s = Shapes.size(size, :cloud, :size, "cloud(size: 30)")
196
+ Shapes.fit(Shapes.cloud_outline(Shapes::CLOUD_BUMPS), width: s)
197
+ end
198
+
199
+ # Invisible, but takes up room.
200
+ def space(width_ = nil, height_ = nil, width: width_ || 10, height: height_ || width)
201
+ w = Shapes.size(width, :space, :width, "space(width: 10)")
202
+ h = Shapes.size(height, :space, :height, "space(width: 10, height: 20)")
203
+ Space.new(w, h)
204
+ end
205
+
206
+ # The empty picture.
207
+ def nothing = Many.new([])
208
+
209
+ # hue in degrees (0..360), saturation and lightness 0..100.
210
+ def hsl(hue = 0, saturation = 70, lightness = 50) = Color.hsl(hue, saturation, lightness)
211
+
212
+ # 0 is black, 100 is white.
213
+ def gray(lightness = 50) = Color.gray(lightness)
214
+
215
+ WORDS = instance_methods(false).freeze
216
+
217
+ # Circles [x, y, radius] for cloud, left to right, all resting on y = 0.
218
+ CLOUD_BUMPS = [[-0.62, -0.3, 0.3], [-0.2, -0.45, 0.45], [0.3, -0.38, 0.38], [0.68, -0.28, 0.28]].freeze
219
+
220
+ # Adds the arc of a circle (centre cx, cy) from one angle to another as
221
+ # cubic curves of at most 90 degrees each. The pen must already be at
222
+ # the start.
223
+ def self.arc_to(pen, cx, cy, r, from, to)
224
+ span = (to - from).to_f
225
+ pieces = [(span.abs / 90.0 - 1e-9).ceil, 1].max
226
+ theta = span / pieces * Math::PI / 180
227
+ k = 4.0 / 3 * Math.tan(theta / 4)
228
+ pieces.times do |i|
229
+ a0 = (from + span / pieces * i) * Math::PI / 180
230
+ a1 = a0 + theta
231
+ pen.curve(cx + r * (Math.sin(a0) + k * Math.cos(a0)), cy + r * (-Math.cos(a0) + k * Math.sin(a0)),
232
+ cx + r * (Math.sin(a1) - k * Math.cos(a1)), cy + r * (-Math.cos(a1) - k * Math.sin(a1)),
233
+ cx + r * Math.sin(a1), cy - r * Math.cos(a1))
234
+ end
235
+ end
236
+
237
+ # The left half of a symmetric outline, given as [x, y] (move) and
238
+ # [c1x, c1y, c2x, c2y, x, y] (curve) from the bottom up to the middle
239
+ # top, becomes the whole outline.
240
+ def self.mirrored(half)
241
+ move, *curves = half
242
+ [[:move, *move]] + curves.map { [:cubic, *_1] } + mirror_tail(curves, move) + [[:close]]
243
+ end
244
+
245
+ def self.mirror_tail(curves, move)
246
+ pts = [move, *curves.map { _1[-2, 2] }]
247
+ curves.each_index.reverse_each.map do |i|
248
+ c = curves[i]
249
+ [:cubic, -c[2], c[3], -c[0], c[1], -pts[i][0], pts[i][1]]
250
+ end
251
+ end
252
+
253
+ # Scales commands evenly to the given width or height and centres them
254
+ # on the origin.
255
+ def self.fit(commands, width: nil, height: nil)
256
+ raw = Path.new(commands:, fill_rule: :nonzero)
257
+ box = raw.box(IDENTITY)
258
+ k = width ? width / box.width : height / box.height
259
+ map_path(raw) { |x, y| [(x - box.cx) * k, (y - box.cy) * k] }
260
+ end
261
+
262
+ def self.map_path(path)
263
+ commands = path.commands.map do |name, *n|
264
+ [name, *n.each_slice(2).flat_map { |x, y| yield(x, y) }]
265
+ end
266
+ Path.new(commands:, fill_rule: path.fill_rule)
267
+ end
268
+
269
+ def self.cloud_outline(bumps)
270
+ pen = Path::Pen.new
271
+ cuts = bumps.each_cons(2).map { |a, b| upper_crossing(a, b) }
272
+ bumps.each_with_index do |(cx, cy, r), i|
273
+ from = i.zero? ? 180.0 : angle(cx, cy, *cuts[i - 1])
274
+ to = i == bumps.size - 1 ? 180.0 : angle(cx, cy, *cuts[i])
275
+ to += 360 while to <= from
276
+ pen.move(*Geometry.polar(r, from).then { |x, y| [cx + x, cy + y] }) if i.zero?
277
+ arc_to(pen, cx, cy, r, from, to)
278
+ end
279
+ pen.close
280
+ pen.commands
281
+ end
282
+
283
+ def self.angle(cx, cy, x, y) = Math.atan2(x - cx, cy - y) * 180 / Math::PI
284
+
285
+ def self.upper_crossing(a, b)
286
+ dx = b[0] - a[0]
287
+ dy = b[1] - a[1]
288
+ d = Math.hypot(dx, dy)
289
+ along = (a[2]**2 - b[2]**2 + d * d) / (2 * d)
290
+ h = Math.sqrt([a[2]**2 - along**2, 0].max)
291
+ [a[0] + (along * dx - h * dy) / d, a[1] + (along * dy + h * dx) / d].then do |p1|
292
+ q = [a[0] + (along * dx + h * dy) / d, a[1] + (along * dy - h * dx) / d]
293
+ p1[1] < q[1] ? p1 : q
294
+ end
295
+ end
296
+
297
+ def self.size(value, word, name, example) = Check.number(value, word:, name:, example:, min: 0)
298
+
299
+ def self.points(list)
300
+ clean = list.map do |point|
301
+ ok = point.is_a?(Array) && point.size == 2 && point.all? { Check.number?(_1) }
302
+ raise Messages.error(ArgumentError, :bad_point, got: point.inspect) unless ok
303
+
304
+ point.map(&:to_f)
305
+ end
306
+ raise Messages.error(ArgumentError, :few_points, got: clean.size) if clean.size < 2
307
+
308
+ clean
309
+ end
310
+ end
311
+
312
+ # Asobi.circle(...) and friends, for require "asobi/pure".
313
+ extend Shapes
314
+ end