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/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
|
data/lib/asobi/random.rb
ADDED
|
@@ -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
|
data/lib/asobi/shapes.rb
ADDED
|
@@ -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
|