vizcore 0.1.0 → 1.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 +4 -4
- data/README.md +70 -117
- data/docs/.nojekyll +0 -0
- data/docs/assets/playground-worker.js +373 -0
- data/docs/assets/playground.css +440 -0
- data/docs/assets/playground.js +652 -0
- data/docs/assets/site.css +744 -0
- data/docs/assets/vizcore-demo.gif +0 -0
- data/docs/assets/vizcore-poster.png +0 -0
- data/docs/assets/vj-tunnel.js +159 -0
- data/docs/index.html +225 -0
- data/docs/playground.html +81 -0
- data/docs/shape_dsl.md +269 -0
- data/examples/README.md +59 -0
- data/examples/assets/README.md +19 -0
- data/examples/audio_inspector.rb +34 -0
- data/examples/club_intro_drop.rb +78 -0
- data/examples/kansai_rubykaigi_visual.rb +70 -0
- data/examples/live_coding_minimal.rb +22 -0
- data/examples/midi_controller_show.rb +78 -0
- data/examples/midi_scene_switch.rb +3 -1
- data/examples/parser_visualizer.rb +48 -0
- data/examples/readme_demo.rb +17 -0
- data/examples/rhythm_geometry.rb +34 -0
- data/examples/ruby_crystal_show.rb +35 -0
- data/examples/shader_playground.rb +18 -0
- data/examples/unyo_liquid.rb +59 -0
- data/examples/vj_ambient_chill_room.rb +124 -0
- data/examples/vj_dnb_jungle.rb +170 -0
- data/examples/vj_festival_mainstage.rb +245 -0
- data/examples/vj_festival_mainstage.yml +17 -0
- data/examples/vj_glitch_industrial.rb +164 -0
- data/examples/vj_hiphop_cipher.rb +167 -0
- data/examples/vj_jpop_idol_live.rb +210 -0
- data/examples/vj_synthwave_retro.rb +173 -0
- data/examples/vj_techno_warehouse.rb +195 -0
- data/frontend/index.html +494 -2
- data/frontend/src/audio-inspector.js +40 -0
- data/frontend/src/custom-shape-param-controls.js +106 -0
- data/frontend/src/live-controls.js +131 -0
- data/frontend/src/main.js +1060 -16
- data/frontend/src/mapping-target-selector.js +109 -0
- data/frontend/src/midi-learn.js +194 -0
- data/frontend/src/performance-monitor.js +183 -0
- data/frontend/src/plugin-runtime.js +130 -0
- data/frontend/src/projector-mode.js +56 -0
- data/frontend/src/renderer/engine.js +157 -3
- data/frontend/src/renderer/layer-manager.js +442 -30
- data/frontend/src/renderer/shader-manager.js +26 -0
- data/frontend/src/runtime-control-preset.js +11 -0
- data/frontend/src/shader-error-overlay.js +29 -0
- data/frontend/src/shader-param-controls.js +93 -0
- data/frontend/src/shaders/builtins.js +380 -2
- data/frontend/src/shaders/post-effects.js +52 -0
- data/frontend/src/shape-editor-controls.js +157 -0
- data/frontend/src/visual-regression.js +67 -0
- data/frontend/src/visual-settings-preset.js +103 -0
- data/frontend/src/visuals/geometry.js +666 -0
- data/frontend/src/visuals/image-renderer.js +291 -0
- data/frontend/src/visuals/particle-system.js +56 -10
- data/frontend/src/visuals/shape-renderer.js +475 -0
- data/frontend/src/visuals/spectrogram-renderer.js +226 -0
- data/frontend/src/visuals/svg-arc.js +104 -0
- data/frontend/src/visuals/text-renderer.js +112 -11
- data/frontend/src/websocket-client.js +12 -1
- data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
- data/lib/vizcore/analysis/beat_detector.rb +4 -2
- data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
- data/lib/vizcore/analysis/feature_recorder.rb +159 -0
- data/lib/vizcore/analysis/feature_replay.rb +84 -0
- data/lib/vizcore/analysis/pipeline.rb +235 -11
- data/lib/vizcore/analysis/tap_tempo.rb +74 -0
- data/lib/vizcore/analysis.rb +4 -0
- data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
- data/lib/vizcore/audio/fixture_input.rb +65 -0
- data/lib/vizcore/audio/input_manager.rb +4 -2
- data/lib/vizcore/audio/mic_input.rb +24 -8
- data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
- data/lib/vizcore/audio.rb +1 -0
- data/lib/vizcore/cli/doctor.rb +159 -0
- data/lib/vizcore/cli/dsl_reference.rb +99 -0
- data/lib/vizcore/cli/layer_docs.rb +46 -0
- data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
- data/lib/vizcore/cli/scene_inspector.rb +136 -0
- data/lib/vizcore/cli/scene_validator.rb +337 -0
- data/lib/vizcore/cli/shader_template.rb +68 -0
- data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
- data/lib/vizcore/cli.rb +689 -18
- data/lib/vizcore/config.rb +103 -2
- data/lib/vizcore/control_preset.rb +68 -0
- data/lib/vizcore/dsl/engine.rb +277 -5
- data/lib/vizcore/dsl/layer_builder.rb +1280 -23
- data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
- data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
- data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
- data/lib/vizcore/dsl/reaction_builder.rb +44 -0
- data/lib/vizcore/dsl/scene_builder.rb +61 -5
- data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
- data/lib/vizcore/dsl/style_builder.rb +68 -0
- data/lib/vizcore/dsl/timeline_builder.rb +138 -0
- data/lib/vizcore/dsl/transition_controller.rb +77 -0
- data/lib/vizcore/dsl.rb +5 -1
- data/lib/vizcore/layer_catalog.rb +275 -0
- data/lib/vizcore/project_manifest.rb +152 -0
- data/lib/vizcore/renderer/png_writer.rb +57 -0
- data/lib/vizcore/renderer/render_sequence.rb +153 -0
- data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
- data/lib/vizcore/renderer/scene_serializer.rb +36 -3
- data/lib/vizcore/renderer/snapshot.rb +38 -0
- data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
- data/lib/vizcore/renderer.rb +5 -0
- data/lib/vizcore/server/frame_broadcaster.rb +143 -8
- data/lib/vizcore/server/gallery_app.rb +155 -0
- data/lib/vizcore/server/gallery_page.rb +100 -0
- data/lib/vizcore/server/gallery_runner.rb +48 -0
- data/lib/vizcore/server/rack_app.rb +203 -4
- data/lib/vizcore/server/runner.rb +391 -22
- data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
- data/lib/vizcore/server/websocket_handler.rb +60 -10
- data/lib/vizcore/server.rb +4 -0
- data/lib/vizcore/shape.rb +719 -0
- data/lib/vizcore/sync/osc_message.rb +103 -0
- data/lib/vizcore/sync/osc_receiver.rb +68 -0
- data/lib/vizcore/sync.rb +4 -0
- data/lib/vizcore/templates/midi_control_scene.rb +3 -1
- data/lib/vizcore/templates/plugin_layer.rb +20 -0
- data/lib/vizcore/templates/plugin_readme.md +23 -0
- data/lib/vizcore/templates/plugin_renderer.js +43 -0
- data/lib/vizcore/templates/plugin_scene.rb +14 -0
- data/lib/vizcore/templates/project_readme.md +7 -23
- data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
- data/lib/vizcore/version.rb +1 -1
- data/lib/vizcore.rb +28 -0
- data/scripts/browser_capture.mjs +75 -0
- data/sig/vizcore.rbs +461 -0
- metadata +94 -3
- data/docs/GETTING_STARTED.md +0 -105
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "png_writer"
|
|
4
|
+
|
|
5
|
+
module Vizcore
|
|
6
|
+
module Renderer
|
|
7
|
+
# Renders a deterministic software preview PNG from a scene frame.
|
|
8
|
+
class SnapshotRenderer
|
|
9
|
+
DEFAULT_WIDTH = 1280
|
|
10
|
+
DEFAULT_HEIGHT = 720
|
|
11
|
+
PATH_DEFAULT_MAX_SEGMENTS = 4096
|
|
12
|
+
PATH_HARD_MAX_SEGMENTS = 65_536
|
|
13
|
+
PATH_MAX_RECURSION = 12
|
|
14
|
+
PALETTE = [
|
|
15
|
+
[56, 189, 248],
|
|
16
|
+
[225, 29, 72],
|
|
17
|
+
[101, 255, 176],
|
|
18
|
+
[244, 114, 182],
|
|
19
|
+
[250, 204, 21]
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
|
|
23
|
+
@width = normalize_dimension(width)
|
|
24
|
+
@height = normalize_dimension(height)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :width, :height
|
|
28
|
+
|
|
29
|
+
# @param scene [Hash]
|
|
30
|
+
# @param audio [Hash]
|
|
31
|
+
# @return [String] PNG bytes
|
|
32
|
+
def render(scene:, audio:)
|
|
33
|
+
canvas = Canvas.new(width: width, height: height)
|
|
34
|
+
canvas.fill_gradient(background_top(audio), background_bottom(audio))
|
|
35
|
+
layers = Array(scene[:layers] || scene["layers"])
|
|
36
|
+
layers = [default_layer] if layers.empty?
|
|
37
|
+
layers.each_with_index { |layer, index| render_layer(canvas, layer, audio, index) }
|
|
38
|
+
PngWriter.encode(width: width, height: height, rgba: canvas.bytes)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def render_layer(canvas, layer, audio, index)
|
|
44
|
+
type = (layer[:type] || layer["type"] || "geometry").to_s
|
|
45
|
+
color = layer_color(layer, audio, index)
|
|
46
|
+
|
|
47
|
+
case type
|
|
48
|
+
when "shader"
|
|
49
|
+
render_shader_layer(canvas, audio, color, index)
|
|
50
|
+
when "particle_field"
|
|
51
|
+
render_particle_layer(canvas, layer, audio, color)
|
|
52
|
+
when "text"
|
|
53
|
+
render_text_layer(canvas, layer, audio, color)
|
|
54
|
+
when "svg", "svg_layer", "image", "image_layer", "photo", "video", "video_layer", "footage"
|
|
55
|
+
render_image_layer(canvas, layer, audio, color)
|
|
56
|
+
when "waveform", "waveform_layer"
|
|
57
|
+
render_waveform_layer(canvas, layer, audio, color)
|
|
58
|
+
when "spectrogram", "spectrogram_layer"
|
|
59
|
+
render_spectrogram_layer(canvas, layer, audio, color)
|
|
60
|
+
when "shape", "shapes", "shape_layer"
|
|
61
|
+
render_shape_layer(canvas, layer, audio, color)
|
|
62
|
+
when "mesh", "mesh_layer", "preset_mesh"
|
|
63
|
+
render_mesh_layer(canvas, layer, audio, color, index)
|
|
64
|
+
else
|
|
65
|
+
render_geometry_layer(canvas, audio, color, index)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_shader_layer(canvas, audio, color, index)
|
|
70
|
+
amplitude = clamp(audio[:amplitude])
|
|
71
|
+
y_base = height * (0.35 + index * 0.12)
|
|
72
|
+
5.times do |wave_index|
|
|
73
|
+
alpha = 0.16 + amplitude * 0.18
|
|
74
|
+
offset = wave_index * height * 0.055
|
|
75
|
+
canvas.draw_wave(y_base + offset, amplitude: amplitude, color: color, alpha: alpha)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_particle_layer(canvas, layer, audio, color)
|
|
80
|
+
amplitude = clamp(audio[:amplitude])
|
|
81
|
+
count = [[Integer(layer.dig(:params, :count) || layer.dig("params", "count") || 420), 80].max, 900].min
|
|
82
|
+
count.times do |index|
|
|
83
|
+
x = (Math.sin(index * 12.9898) * 43_758.5453).abs % width
|
|
84
|
+
y = (Math.sin(index * 78.233) * 12_345.6789).abs % height
|
|
85
|
+
radius = 1 + (index % 3) + (amplitude * 2).round
|
|
86
|
+
canvas.fill_circle(x, y, radius, color, alpha: 0.35 + amplitude * 0.45)
|
|
87
|
+
end
|
|
88
|
+
rescue ArgumentError, TypeError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_text_layer(canvas, layer, audio, color)
|
|
93
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
94
|
+
content = params[:content] || params["content"] || layer[:name] || layer["name"] || "Vizcore"
|
|
95
|
+
canvas.draw_label(
|
|
96
|
+
content.to_s,
|
|
97
|
+
x: width * 0.5,
|
|
98
|
+
y: height * 0.72,
|
|
99
|
+
color: color,
|
|
100
|
+
alpha: 0.62 + clamp(audio[:beat_pulse]) * 0.28,
|
|
101
|
+
letter_spacing: normalize_letter_spacing(params)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def render_image_layer(canvas, layer, audio, color)
|
|
106
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
107
|
+
label = params[:file] || params["file"] || layer[:name] || layer["name"] || "image"
|
|
108
|
+
scale = Float(params[:scale] || params["scale"] || 1).clamp(0.1, 4.0)
|
|
109
|
+
pulse = clamp(audio[:beat_pulse])
|
|
110
|
+
size = [width, height].min * (0.18 + pulse * 0.06) * scale
|
|
111
|
+
x = width * 0.5
|
|
112
|
+
y = height * 0.5
|
|
113
|
+
canvas.draw_rect_outline(x - size / 2, y - size / 2, size, size, color, alpha: 0.72)
|
|
114
|
+
canvas.draw_label(File.basename(label.to_s), x: x, y: y + size * 0.62, color: color, alpha: 0.66)
|
|
115
|
+
rescue ArgumentError, TypeError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def render_waveform_layer(canvas, layer, audio, color)
|
|
120
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
121
|
+
amplitude = clamp(audio[:amplitude])
|
|
122
|
+
style = (params[:style] || params["style"] || "line").to_s
|
|
123
|
+
height_scale = normalize_waveform_height(params)
|
|
124
|
+
alpha = 0.45 + amplitude * 0.35
|
|
125
|
+
y_base = height * 0.5
|
|
126
|
+
|
|
127
|
+
canvas.draw_wave(y_base, amplitude: amplitude, color: color, alpha: alpha, height_scale: height_scale)
|
|
128
|
+
return unless %w[mirror ribbon].include?(style)
|
|
129
|
+
|
|
130
|
+
canvas.draw_wave(y_base, amplitude: amplitude, color: color, alpha: alpha * 0.68, height_scale: -height_scale)
|
|
131
|
+
rescue ArgumentError, TypeError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def render_spectrogram_layer(canvas, layer, audio, color)
|
|
136
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
137
|
+
fft = Array(audio[:fft] || audio["fft"])
|
|
138
|
+
bins = [[Integer(params[:bins] || params["bins"] || 32), 8].max, 96].min
|
|
139
|
+
gain = Float(params[:gain] || params["gain"] || 1).clamp(0.1, 8.0)
|
|
140
|
+
band_width = width.to_f / bins
|
|
141
|
+
rows = 18
|
|
142
|
+
row_height = height * 0.5 / rows
|
|
143
|
+
top = height * 0.22
|
|
144
|
+
|
|
145
|
+
rows.times do |row|
|
|
146
|
+
age = row.to_f / [rows - 1, 1].max
|
|
147
|
+
bins.times do |bin|
|
|
148
|
+
value = clamp(Float(fft[bin % [fft.length, 1].max] || 0) * gain)
|
|
149
|
+
alpha = (0.08 + value * 0.52) * (1.0 - age * 0.62)
|
|
150
|
+
x = bin * band_width
|
|
151
|
+
y = top + row * row_height
|
|
152
|
+
canvas.fill_rect(x, y, band_width.ceil + 1, row_height.ceil + 1, color, alpha: alpha)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
rescue ArgumentError, TypeError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def render_shape_layer(canvas, layer, audio, color)
|
|
160
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
161
|
+
shapes = Array(params[:shapes] || params["shapes"])
|
|
162
|
+
context = shape_coordinate_context(params)
|
|
163
|
+
pulse = clamp(audio[:beat_pulse])
|
|
164
|
+
alpha = 0.58 + pulse * 0.24
|
|
165
|
+
|
|
166
|
+
shapes.each do |shape|
|
|
167
|
+
shape_hash = Hash(shape)
|
|
168
|
+
case (shape_hash[:kind] || shape_hash["kind"]).to_s
|
|
169
|
+
when "circle"
|
|
170
|
+
render_circle_shape(canvas, shape_hash, color, alpha, context)
|
|
171
|
+
when "line"
|
|
172
|
+
render_line_shape(canvas, shape_hash, color, alpha, context)
|
|
173
|
+
when "rect"
|
|
174
|
+
render_rect_shape(canvas, shape_hash, color, alpha, context)
|
|
175
|
+
when "polygon", "polyline"
|
|
176
|
+
render_polygon_shape(canvas, shape_hash, color, alpha, context)
|
|
177
|
+
when "path"
|
|
178
|
+
render_path_shape(canvas, shape_hash, color, alpha, context)
|
|
179
|
+
when "star"
|
|
180
|
+
render_star_shape(canvas, shape_hash, color, alpha, context)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
rescue ArgumentError, TypeError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def render_circle_shape(canvas, shape, color, alpha, context)
|
|
188
|
+
count = [[Integer(shape[:count] || shape["count"] || 1), 1].max, 32].min
|
|
189
|
+
radius = shape_length(shape[:radius] || shape["radius"] || 100, context, :radius)
|
|
190
|
+
center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
|
|
191
|
+
|
|
192
|
+
count.times do |index|
|
|
193
|
+
ring_radius = radius * ((index + 1).to_f / count)
|
|
194
|
+
render_polyline_shape(canvas, circle_points(center, ring_radius), shape, color, alpha, context, closed: true)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def render_line_shape(canvas, shape, color, alpha, context)
|
|
199
|
+
defaults = context[:units] == :legacy || context[:units] == :ndc ? [-0.8, 0, 0.8, 0] : [-100, 0, 100, 0]
|
|
200
|
+
from = shape_point(shape[:x1] || shape["x1"] || defaults[0], shape[:y1] || shape["y1"] || defaults[1], context)
|
|
201
|
+
to = shape_point(shape[:x2] || shape["x2"] || defaults[2], shape[:y2] || shape["y2"] || defaults[3], context)
|
|
202
|
+
draw_shape_segment(canvas, from, to, shape, color, alpha, context)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def render_rect_shape(canvas, shape, color, alpha, context)
|
|
206
|
+
center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
|
|
207
|
+
half_width = shape_length(shape[:width] || shape["width"] || 100, context, :x) / 2.0
|
|
208
|
+
half_height = shape_length(shape[:height] || shape["height"] || 100, context, :y) / 2.0
|
|
209
|
+
points = [
|
|
210
|
+
[center[0] - half_width, center[1] - half_height],
|
|
211
|
+
[center[0] + half_width, center[1] - half_height],
|
|
212
|
+
[center[0] + half_width, center[1] + half_height],
|
|
213
|
+
[center[0] - half_width, center[1] + half_height]
|
|
214
|
+
]
|
|
215
|
+
render_polyline_shape(canvas, points, shape, color, alpha, context, closed: true)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def render_polygon_shape(canvas, shape, color, alpha, context)
|
|
219
|
+
points = Array(shape[:points] || shape["points"]).filter_map do |point|
|
|
220
|
+
values = Array(point)
|
|
221
|
+
next if values.length < 2
|
|
222
|
+
|
|
223
|
+
shape_point(values[0], values[1], context)
|
|
224
|
+
end
|
|
225
|
+
closed = (shape[:kind] || shape["kind"]).to_s == "polygon" ? shape.fetch(:closed, shape.fetch("closed", true)) : false
|
|
226
|
+
render_polyline_shape(canvas, points, shape, color, alpha, context, closed: closed)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def render_star_shape(canvas, shape, color, alpha, context)
|
|
230
|
+
tips = [[Integer(shape[:points] || shape["points"] || 5), 3].max, 128].min
|
|
231
|
+
center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
|
|
232
|
+
radius = shape_length(shape[:radius] || shape["radius"] || 100, context, :radius)
|
|
233
|
+
inner_radius = shape_length(shape[:inner_radius] || shape["inner_radius"] || Float(shape[:radius] || shape["radius"] || 100) * 0.5, context, :radius)
|
|
234
|
+
rotation = Float(shape[:rotation] || shape["rotation"] || -90) * Math::PI / 180.0
|
|
235
|
+
points = (tips * 2).times.map do |index|
|
|
236
|
+
angle = rotation + (index.to_f / (tips * 2)) * Math::PI * 2
|
|
237
|
+
point_radius = index.even? ? radius : inner_radius
|
|
238
|
+
[center[0] + Math.cos(angle) * point_radius, center[1] - Math.sin(angle) * point_radius]
|
|
239
|
+
end
|
|
240
|
+
render_polyline_shape(canvas, points, shape, color, alpha, context, closed: true)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def render_path_shape(canvas, shape, color, alpha, context)
|
|
244
|
+
detail = [[Integer(shape[:detail] || shape["detail"] || 32), 4].max, 128].min
|
|
245
|
+
tolerance = path_tolerance(shape)
|
|
246
|
+
segment_budget = { remaining: path_segment_limit(shape) }
|
|
247
|
+
current = nil
|
|
248
|
+
subpath_start = nil
|
|
249
|
+
Array(shape[:commands] || shape["commands"]).each do |entry|
|
|
250
|
+
command, *values = Array(entry)
|
|
251
|
+
values = values.map { |value| Float(value) }
|
|
252
|
+
case command.to_s.upcase
|
|
253
|
+
when "M"
|
|
254
|
+
current = values.first(2)
|
|
255
|
+
subpath_start = current
|
|
256
|
+
when "L"
|
|
257
|
+
next unless current && values.length >= 2
|
|
258
|
+
|
|
259
|
+
current = draw_raw_path_segment(canvas, current, values.first(2), shape, color, alpha, context, segment_budget)
|
|
260
|
+
when "H"
|
|
261
|
+
next unless current && values.length >= 1
|
|
262
|
+
|
|
263
|
+
current = draw_raw_path_segment(canvas, current, [values[0], current[1]], shape, color, alpha, context, segment_budget)
|
|
264
|
+
when "V"
|
|
265
|
+
next unless current && values.length >= 1
|
|
266
|
+
|
|
267
|
+
current = draw_raw_path_segment(canvas, current, [current[0], values[0]], shape, color, alpha, context, segment_budget)
|
|
268
|
+
when "Q"
|
|
269
|
+
next unless current && values.length >= 4
|
|
270
|
+
|
|
271
|
+
current = draw_quadratic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget)
|
|
272
|
+
when "C"
|
|
273
|
+
next unless current && values.length >= 6
|
|
274
|
+
|
|
275
|
+
current = draw_cubic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget)
|
|
276
|
+
when "A"
|
|
277
|
+
next unless current && values.length >= 7
|
|
278
|
+
|
|
279
|
+
current = draw_arc_path(canvas, current, values, detail, shape, color, alpha, context, segment_budget)
|
|
280
|
+
when "Z"
|
|
281
|
+
if current && subpath_start
|
|
282
|
+
current = draw_raw_path_segment(canvas, current, subpath_start, shape, color, alpha, context, segment_budget)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def render_polyline_shape(canvas, points, shape, color, alpha, context, closed:)
|
|
289
|
+
return if points.length < 2
|
|
290
|
+
|
|
291
|
+
points.each_cons(2) { |from, to| draw_shape_segment(canvas, from, to, shape, color, alpha, context) }
|
|
292
|
+
draw_shape_segment(canvas, points.last, points.first, shape, color, alpha, context) if closed && points.length > 2
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget = nil)
|
|
296
|
+
return to if segment_budget && segment_budget[:remaining] <= 0
|
|
297
|
+
|
|
298
|
+
draw_shape_segment(canvas, shape_point(from[0], from[1], context), shape_point(to[0], to[1], context), shape, color, alpha, context)
|
|
299
|
+
segment_budget[:remaining] -= 1 if segment_budget
|
|
300
|
+
to
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def draw_quadratic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget = nil)
|
|
304
|
+
previous = current
|
|
305
|
+
control = values.first(2)
|
|
306
|
+
endpoint = values.last(2)
|
|
307
|
+
if tolerance
|
|
308
|
+
draw_adaptive_quadratic_path(canvas, current, control, endpoint, tolerance, shape, color, alpha, context, segment_budget)
|
|
309
|
+
return endpoint
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
1.upto(detail) do |step|
|
|
313
|
+
break if segment_budget && segment_budget[:remaining] <= 0
|
|
314
|
+
|
|
315
|
+
t = step.to_f / detail
|
|
316
|
+
point = [
|
|
317
|
+
quadratic_point(current[0], control[0], endpoint[0], t),
|
|
318
|
+
quadratic_point(current[1], control[1], endpoint[1], t)
|
|
319
|
+
]
|
|
320
|
+
draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
|
|
321
|
+
previous = point
|
|
322
|
+
end
|
|
323
|
+
endpoint
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def draw_cubic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget = nil)
|
|
327
|
+
previous = current
|
|
328
|
+
c1 = values[0, 2]
|
|
329
|
+
c2 = values[2, 2]
|
|
330
|
+
endpoint = values[4, 2]
|
|
331
|
+
if tolerance
|
|
332
|
+
draw_adaptive_cubic_path(canvas, current, c1, c2, endpoint, tolerance, shape, color, alpha, context, segment_budget)
|
|
333
|
+
return endpoint
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
1.upto(detail) do |step|
|
|
337
|
+
break if segment_budget && segment_budget[:remaining] <= 0
|
|
338
|
+
|
|
339
|
+
t = step.to_f / detail
|
|
340
|
+
point = [
|
|
341
|
+
cubic_point(current[0], c1[0], c2[0], endpoint[0], t),
|
|
342
|
+
cubic_point(current[1], c1[1], c2[1], endpoint[1], t)
|
|
343
|
+
]
|
|
344
|
+
draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
|
|
345
|
+
previous = point
|
|
346
|
+
end
|
|
347
|
+
endpoint
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def draw_arc_path(canvas, current, values, detail, shape, color, alpha, context, segment_budget = nil)
|
|
351
|
+
endpoint = values[5, 2]
|
|
352
|
+
arc = svg_arc_description(
|
|
353
|
+
from: current,
|
|
354
|
+
to: endpoint,
|
|
355
|
+
rx: values[0],
|
|
356
|
+
ry: values[1],
|
|
357
|
+
x_axis_rotation: values[2],
|
|
358
|
+
large_arc: arc_flag(values[3]),
|
|
359
|
+
sweep: arc_flag(values[4])
|
|
360
|
+
)
|
|
361
|
+
unless arc
|
|
362
|
+
return draw_raw_path_segment(canvas, current, endpoint, shape, color, alpha, context, segment_budget)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
previous = current
|
|
366
|
+
segments = svg_arc_segment_count(arc, detail)
|
|
367
|
+
1.upto(segments) do |step|
|
|
368
|
+
break if segment_budget && segment_budget[:remaining] <= 0
|
|
369
|
+
|
|
370
|
+
point = svg_arc_point(arc, step.to_f / segments)
|
|
371
|
+
draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
|
|
372
|
+
previous = point
|
|
373
|
+
end
|
|
374
|
+
endpoint
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def path_segment_limit(shape)
|
|
378
|
+
raw_value = shape[:max_segments] || shape["max_segments"] || PATH_DEFAULT_MAX_SEGMENTS
|
|
379
|
+
[[Integer(raw_value), 1].max, PATH_HARD_MAX_SEGMENTS].min
|
|
380
|
+
rescue ArgumentError, TypeError
|
|
381
|
+
PATH_DEFAULT_MAX_SEGMENTS
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def path_tolerance(shape)
|
|
385
|
+
return unless shape.key?(:tolerance) || shape.key?("tolerance")
|
|
386
|
+
|
|
387
|
+
value = Float(shape[:tolerance] || shape["tolerance"])
|
|
388
|
+
value if value.finite? && value >= 0
|
|
389
|
+
rescue ArgumentError, TypeError
|
|
390
|
+
nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def draw_adaptive_quadratic_path(canvas, from, control, to, tolerance, shape, color, alpha, context, segment_budget, depth = 0)
|
|
394
|
+
return if segment_budget && segment_budget[:remaining] <= 0
|
|
395
|
+
|
|
396
|
+
if depth >= PATH_MAX_RECURSION || point_line_distance(control, from, to) <= tolerance
|
|
397
|
+
draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget)
|
|
398
|
+
return
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
left_control = midpoint(from, control)
|
|
402
|
+
right_control = midpoint(control, to)
|
|
403
|
+
center = midpoint(left_control, right_control)
|
|
404
|
+
draw_adaptive_quadratic_path(canvas, from, left_control, center, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
|
|
405
|
+
draw_adaptive_quadratic_path(canvas, center, right_control, to, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def draw_adaptive_cubic_path(canvas, from, c1, c2, to, tolerance, shape, color, alpha, context, segment_budget, depth = 0)
|
|
409
|
+
return if segment_budget && segment_budget[:remaining] <= 0
|
|
410
|
+
|
|
411
|
+
flatness = [point_line_distance(c1, from, to), point_line_distance(c2, from, to)].max
|
|
412
|
+
if depth >= PATH_MAX_RECURSION || flatness <= tolerance
|
|
413
|
+
draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget)
|
|
414
|
+
return
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
p01 = midpoint(from, c1)
|
|
418
|
+
p12 = midpoint(c1, c2)
|
|
419
|
+
p23 = midpoint(c2, to)
|
|
420
|
+
p012 = midpoint(p01, p12)
|
|
421
|
+
p123 = midpoint(p12, p23)
|
|
422
|
+
center = midpoint(p012, p123)
|
|
423
|
+
draw_adaptive_cubic_path(canvas, from, p01, p012, center, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
|
|
424
|
+
draw_adaptive_cubic_path(canvas, center, p123, p23, to, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def midpoint(from, to)
|
|
428
|
+
[(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def point_line_distance(point, from, to)
|
|
432
|
+
dx = to[0] - from[0]
|
|
433
|
+
dy = to[1] - from[1]
|
|
434
|
+
length = Math.sqrt((dx * dx) + (dy * dy))
|
|
435
|
+
return Math.sqrt(((point[0] - from[0])**2) + ((point[1] - from[1])**2)) if length <= 0
|
|
436
|
+
|
|
437
|
+
((dy * point[0]) - (dx * point[1]) + (to[0] * from[1]) - (to[1] * from[0])).abs / length
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def draw_shape_segment(canvas, from, to, shape, color, alpha, context)
|
|
441
|
+
from = apply_shape_transform(from, shape, context)
|
|
442
|
+
to = apply_shape_transform(to, shape, context)
|
|
443
|
+
canvas.draw_line(from[0], from[1], to[0], to[1], color, alpha: alpha * shape_opacity(shape))
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def shape_coordinate_context(params)
|
|
447
|
+
units = (params[:units] || params["units"]).to_s.strip.downcase
|
|
448
|
+
version = Integer(params[:shape_schema_version] || params["shape_schema_version"] || 1)
|
|
449
|
+
{ units: (units.empty? ? (version >= 2 ? :logical : :legacy) : units.to_sym) }
|
|
450
|
+
rescue ArgumentError, TypeError
|
|
451
|
+
{ units: :legacy }
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def shape_point(x, y, context)
|
|
455
|
+
[shape_coordinate(x, context, :x), shape_coordinate(y, context, :y)]
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def shape_coordinate(value, context, axis)
|
|
459
|
+
numeric = Float(value || 0)
|
|
460
|
+
case context[:units]
|
|
461
|
+
when :ndc
|
|
462
|
+
axis == :x ? width * 0.5 + numeric * width * 0.5 : height * 0.5 - numeric * height * 0.5
|
|
463
|
+
when :logical, :center, :center_origin, :px
|
|
464
|
+
axis == :x ? width * 0.5 + numeric : height * 0.5 - numeric
|
|
465
|
+
when :screen, :canvas, :viewport
|
|
466
|
+
numeric
|
|
467
|
+
else
|
|
468
|
+
legacy_shape_coordinate(numeric, axis)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def legacy_shape_coordinate(value, axis)
|
|
473
|
+
return axis == :x ? width * 0.5 + value * width * 0.5 : height * 0.5 - value * height * 0.5 if value.abs <= 1.5
|
|
474
|
+
|
|
475
|
+
value
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def shape_length(value, context, _axis)
|
|
479
|
+
numeric = Float(value || 0).abs
|
|
480
|
+
return numeric * [width, height].min * 0.5 if context[:units] == :ndc || numeric <= 2
|
|
481
|
+
|
|
482
|
+
numeric
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def circle_points(center, radius)
|
|
486
|
+
segments = 96
|
|
487
|
+
segments.times.map do |index|
|
|
488
|
+
angle = (index.to_f / segments) * Math::PI * 2
|
|
489
|
+
[center[0] + Math.cos(angle) * radius, center[1] + Math.sin(angle) * radius]
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def apply_shape_transform(point, shape, context)
|
|
494
|
+
transform = shape_transform(shape, context)
|
|
495
|
+
shifted_x = (point[0] - transform[:origin][0]) * transform[:scale][:x]
|
|
496
|
+
shifted_y = (point[1] - transform[:origin][1]) * transform[:scale][:y]
|
|
497
|
+
radians = -transform[:rotate] * Math::PI / 180.0
|
|
498
|
+
cos = Math.cos(radians)
|
|
499
|
+
sin = Math.sin(radians)
|
|
500
|
+
rotated_x = shifted_x * cos - shifted_y * sin
|
|
501
|
+
rotated_y = shifted_x * sin + shifted_y * cos
|
|
502
|
+
|
|
503
|
+
[
|
|
504
|
+
rotated_x + transform[:origin][0] + transform[:translate][:x],
|
|
505
|
+
rotated_y + transform[:origin][1] + transform[:translate][:y]
|
|
506
|
+
]
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def shape_transform(shape, context)
|
|
510
|
+
transform = Hash(shape[:transform] || shape["transform"] || {})
|
|
511
|
+
{
|
|
512
|
+
translate: shape_vector_pair(shape_hash_value(transform, :translate) || shape_hash_value(shape, :translate), context),
|
|
513
|
+
origin: shape_origin_pair(shape_hash_value(transform, :origin) || shape_hash_value(shape, :origin), context),
|
|
514
|
+
rotate: Float(shape_hash_value(transform, :rotate) || shape_hash_value(shape, :rotate) || shape_hash_value(shape, :rotation) || 0),
|
|
515
|
+
scale: shape_scale(shape_hash_value(transform, :scale) || shape_hash_value(shape, :scale))
|
|
516
|
+
}
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def shape_vector_pair(value, context)
|
|
520
|
+
if value.is_a?(Array)
|
|
521
|
+
return { x: shape_vector(value[0], context, :x), y: shape_vector(value[1], context, :y) }
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
values = value.is_a?(Hash) ? value : {}
|
|
525
|
+
{ x: shape_vector(shape_hash_value(values, :x) || 0, context, :x), y: shape_vector(shape_hash_value(values, :y) || 0, context, :y) }
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def shape_origin_pair(value, context)
|
|
529
|
+
if value.is_a?(Array)
|
|
530
|
+
return shape_point(value[0], value[1], context)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
values = value.is_a?(Hash) ? value : {}
|
|
534
|
+
shape_point(shape_hash_value(values, :x) || 0, shape_hash_value(values, :y) || 0, context)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def shape_vector(value, context, axis)
|
|
538
|
+
numeric = Float(value || 0)
|
|
539
|
+
case context[:units]
|
|
540
|
+
when :ndc
|
|
541
|
+
axis == :x ? numeric * width * 0.5 : -numeric * height * 0.5
|
|
542
|
+
else
|
|
543
|
+
axis == :x ? numeric : -numeric
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def shape_scale(value)
|
|
548
|
+
if value.is_a?(Hash)
|
|
549
|
+
return {
|
|
550
|
+
x: Float(shape_hash_value(value, :x) || 1).clamp(-8.0, 8.0),
|
|
551
|
+
y: Float(shape_hash_value(value, :y) || 1).clamp(-8.0, 8.0)
|
|
552
|
+
}
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
scale = Float(value || 1).clamp(-8.0, 8.0)
|
|
556
|
+
{ x: scale, y: scale }
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def shape_opacity(shape)
|
|
560
|
+
Float(shape[:opacity] || shape["opacity"] || 1).clamp(0.0, 1.0)
|
|
561
|
+
rescue ArgumentError, TypeError
|
|
562
|
+
1.0
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def shape_hash_value(hash, key)
|
|
566
|
+
hash[key] || hash[key.to_s]
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def quadratic_point(from, control, to, t)
|
|
570
|
+
inv = 1.0 - t
|
|
571
|
+
inv * inv * from + 2 * inv * t * control + t * t * to
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def cubic_point(from, c1, c2, to, t)
|
|
575
|
+
inv = 1.0 - t
|
|
576
|
+
inv * inv * inv * from + 3 * inv * inv * t * c1 + 3 * inv * t * t * c2 + t * t * t * to
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def svg_arc_description(from:, to:, rx:, ry:, x_axis_rotation:, large_arc:, sweep:)
|
|
580
|
+
return if same_point?(from, to)
|
|
581
|
+
|
|
582
|
+
radius_x = Float(rx || 0).abs
|
|
583
|
+
radius_y = Float(ry || 0).abs
|
|
584
|
+
return if radius_x <= 0 || radius_y <= 0
|
|
585
|
+
|
|
586
|
+
rotation = Float(x_axis_rotation || 0) * Math::PI / 180.0
|
|
587
|
+
cos = Math.cos(rotation)
|
|
588
|
+
sin = Math.sin(rotation)
|
|
589
|
+
dx = (from[0] - to[0]) / 2.0
|
|
590
|
+
dy = (from[1] - to[1]) / 2.0
|
|
591
|
+
x1p = cos * dx + sin * dy
|
|
592
|
+
y1p = -sin * dx + cos * dy
|
|
593
|
+
|
|
594
|
+
scale = (x1p * x1p / (radius_x * radius_x)) + (y1p * y1p / (radius_y * radius_y))
|
|
595
|
+
if scale > 1
|
|
596
|
+
multiplier = Math.sqrt(scale)
|
|
597
|
+
radius_x *= multiplier
|
|
598
|
+
radius_y *= multiplier
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
center = svg_arc_center(
|
|
602
|
+
from: from,
|
|
603
|
+
to: to,
|
|
604
|
+
radius_x: radius_x,
|
|
605
|
+
radius_y: radius_y,
|
|
606
|
+
x1p: x1p,
|
|
607
|
+
y1p: y1p,
|
|
608
|
+
rotation_cos: cos,
|
|
609
|
+
rotation_sin: sin,
|
|
610
|
+
large_arc: large_arc,
|
|
611
|
+
sweep: sweep
|
|
612
|
+
)
|
|
613
|
+
return unless center
|
|
614
|
+
|
|
615
|
+
start_vector = [(x1p - center[:cxp]) / radius_x, (y1p - center[:cyp]) / radius_y]
|
|
616
|
+
end_vector = [(-x1p - center[:cxp]) / radius_x, (-y1p - center[:cyp]) / radius_y]
|
|
617
|
+
start_angle = vector_angle([1.0, 0.0], start_vector)
|
|
618
|
+
delta_angle = vector_angle(start_vector, end_vector)
|
|
619
|
+
delta_angle -= Math::PI * 2 if !sweep && delta_angle.positive?
|
|
620
|
+
delta_angle += Math::PI * 2 if sweep && delta_angle.negative?
|
|
621
|
+
|
|
622
|
+
{
|
|
623
|
+
cx: center[:cx],
|
|
624
|
+
cy: center[:cy],
|
|
625
|
+
rx: radius_x,
|
|
626
|
+
ry: radius_y,
|
|
627
|
+
rotation: rotation,
|
|
628
|
+
start_angle: start_angle,
|
|
629
|
+
delta_angle: delta_angle
|
|
630
|
+
}
|
|
631
|
+
rescue ArgumentError, TypeError
|
|
632
|
+
nil
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def svg_arc_center(from:, to:, radius_x:, radius_y:, x1p:, y1p:, rotation_cos:, rotation_sin:, large_arc:, sweep:)
|
|
636
|
+
rx2 = radius_x * radius_x
|
|
637
|
+
ry2 = radius_y * radius_y
|
|
638
|
+
x1p2 = x1p * x1p
|
|
639
|
+
y1p2 = y1p * y1p
|
|
640
|
+
denominator = rx2 * y1p2 + ry2 * x1p2
|
|
641
|
+
return if denominator.zero?
|
|
642
|
+
|
|
643
|
+
numerator = [rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2, 0.0].max
|
|
644
|
+
sign = large_arc == sweep ? -1.0 : 1.0
|
|
645
|
+
coefficient = sign * Math.sqrt(numerator / denominator)
|
|
646
|
+
cxp = coefficient * ((radius_x * y1p) / radius_y)
|
|
647
|
+
cyp = coefficient * (-(radius_y * x1p) / radius_x)
|
|
648
|
+
{
|
|
649
|
+
cxp: cxp,
|
|
650
|
+
cyp: cyp,
|
|
651
|
+
cx: rotation_cos * cxp - rotation_sin * cyp + (from[0] + to[0]) / 2.0,
|
|
652
|
+
cy: rotation_sin * cxp + rotation_cos * cyp + (from[1] + to[1]) / 2.0
|
|
653
|
+
}
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def svg_arc_point(arc, progress)
|
|
657
|
+
angle = arc[:start_angle] + arc[:delta_angle] * progress
|
|
658
|
+
cos_rotation = Math.cos(arc[:rotation])
|
|
659
|
+
sin_rotation = Math.sin(arc[:rotation])
|
|
660
|
+
x = Math.cos(angle) * arc[:rx]
|
|
661
|
+
y = Math.sin(angle) * arc[:ry]
|
|
662
|
+
[
|
|
663
|
+
arc[:cx] + cos_rotation * x - sin_rotation * y,
|
|
664
|
+
arc[:cy] + sin_rotation * x + cos_rotation * y
|
|
665
|
+
]
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def svg_arc_segment_count(arc, detail)
|
|
669
|
+
[((arc[:delta_angle].abs / (Math::PI * 2)) * detail).ceil, 1].max
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def vector_angle(from, to)
|
|
673
|
+
cross = from[0] * to[1] - from[1] * to[0]
|
|
674
|
+
dot = from[0] * to[0] + from[1] * to[1]
|
|
675
|
+
Math.atan2(cross, dot)
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def same_point?(from, to)
|
|
679
|
+
(from[0] - to[0]).abs < 1e-9 && (from[1] - to[1]).abs < 1e-9
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def arc_flag(value)
|
|
683
|
+
!Float(value || 0).zero?
|
|
684
|
+
rescue ArgumentError, TypeError
|
|
685
|
+
false
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def render_mesh_layer(canvas, layer, audio, color, index)
|
|
689
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
690
|
+
amplitude = clamp(audio[:amplitude])
|
|
691
|
+
high = audio.dig(:bands, :high) || audio.dig("bands", "high")
|
|
692
|
+
deform = clamp(params[:deform] || params["deform"] || high || amplitude)
|
|
693
|
+
scale = Float(params[:scale] || params["scale"] || 1).clamp(0.1, 3.0)
|
|
694
|
+
radius = [width, height].min * (0.20 + amplitude * 0.08 + deform * 0.08) * scale
|
|
695
|
+
cx = width * (0.5 + (index - 1) * 0.06)
|
|
696
|
+
cy = height * 0.48
|
|
697
|
+
top = [cx, cy - radius * 0.62]
|
|
698
|
+
bottom = [cx, cy + radius * 0.62]
|
|
699
|
+
ring = 6.times.map do |point_index|
|
|
700
|
+
angle = (point_index.to_f / 6) * Math::PI * 2 + Math::PI / 6
|
|
701
|
+
[cx + Math.cos(angle) * radius * 0.72, cy + Math.sin(angle) * radius * 0.36]
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
ring.each_with_index do |point, point_index|
|
|
705
|
+
next_point = ring[(point_index + 1) % ring.length]
|
|
706
|
+
canvas.draw_line(point[0], point[1], next_point[0], next_point[1], color, alpha: 0.62)
|
|
707
|
+
canvas.draw_line(top[0], top[1], point[0], point[1], color, alpha: 0.54)
|
|
708
|
+
canvas.draw_line(bottom[0], bottom[1], next_point[0], next_point[1], color, alpha: 0.42)
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
3.times do |point_index|
|
|
712
|
+
from = ring[point_index]
|
|
713
|
+
to = ring[point_index + 3]
|
|
714
|
+
canvas.draw_line(from[0], from[1], to[0], to[1], color, alpha: 0.32 + deform * 0.2)
|
|
715
|
+
end
|
|
716
|
+
rescue ArgumentError, TypeError
|
|
717
|
+
nil
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def render_geometry_layer(canvas, audio, color, index)
|
|
721
|
+
amplitude = clamp(audio[:amplitude])
|
|
722
|
+
size = [width, height].min * (0.22 + amplitude * 0.18)
|
|
723
|
+
cx = width * (0.5 + (index - 1) * 0.08)
|
|
724
|
+
cy = height * 0.48
|
|
725
|
+
offset = size * 0.24
|
|
726
|
+
canvas.draw_rect_outline(cx - size / 2, cy - size / 2, size, size, color, alpha: 0.78)
|
|
727
|
+
canvas.draw_rect_outline(cx - size / 2 + offset, cy - size / 2 - offset, size, size, color, alpha: 0.46)
|
|
728
|
+
4.times do |corner|
|
|
729
|
+
x1 = cx - size / 2 + (corner.even? ? 0 : size)
|
|
730
|
+
y1 = cy - size / 2 + (corner < 2 ? 0 : size)
|
|
731
|
+
canvas.draw_line(x1, y1, x1 + offset, y1 - offset, color, alpha: 0.55)
|
|
732
|
+
end
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def background_top(audio)
|
|
736
|
+
amplitude = clamp(audio[:amplitude])
|
|
737
|
+
high = clamp(audio.dig(:bands, :high))
|
|
738
|
+
[4 + (amplitude * 22).round, 10 + (high * 38).round, 24 + (amplitude * 34).round]
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def background_bottom(audio)
|
|
742
|
+
low = clamp(audio.dig(:bands, :low))
|
|
743
|
+
[1 + (low * 30).round, 4 + (low * 18).round, 12 + (low * 44).round]
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def layer_color(layer, audio, index)
|
|
747
|
+
base = configured_layer_color(layer, index) || PALETTE[index % PALETTE.length]
|
|
748
|
+
beat = clamp(audio[:beat_pulse])
|
|
749
|
+
name_factor = (layer[:shader] || layer["shader"] || layer[:name] || layer["name"]).to_s.bytes.sum % 38
|
|
750
|
+
base.map { |value| [[value + name_factor + (beat * 30).round, 255].min, 0].max }
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def configured_layer_color(layer, index)
|
|
754
|
+
params = Hash(layer[:params] || layer["params"] || {})
|
|
755
|
+
color = configured_color(params) || palette_color(params, index)
|
|
756
|
+
parse_hex_color(color)
|
|
757
|
+
rescue StandardError
|
|
758
|
+
nil
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def configured_color(params)
|
|
762
|
+
[params[:color], params["color"]].map { |value| value.to_s.strip }.find { |value| !value.empty? }
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
def palette_color(params, index)
|
|
766
|
+
palette = Array(params[:palette] || params["palette"]).map { |color| color.to_s.strip }.reject(&:empty?)
|
|
767
|
+
return nil if palette.empty?
|
|
768
|
+
|
|
769
|
+
palette[index % palette.length]
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def parse_hex_color(value)
|
|
773
|
+
match = value.to_s.strip.match(/\A#(?<hex>[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\z/)
|
|
774
|
+
return nil unless match
|
|
775
|
+
|
|
776
|
+
hex = match[:hex]
|
|
777
|
+
hex = hex.chars.map { |char| "#{char}#{char}" }.join if hex.length == 3
|
|
778
|
+
[hex[0, 2], hex[2, 2], hex[4, 2]].map { |component| component.to_i(16) }
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def default_layer
|
|
782
|
+
{ type: "geometry", name: "snapshot" }
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
def normalize_dimension(value)
|
|
786
|
+
Integer(value).clamp(64, 4096)
|
|
787
|
+
rescue ArgumentError, TypeError
|
|
788
|
+
DEFAULT_WIDTH
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def clamp(value)
|
|
792
|
+
Float(value || 0).clamp(0.0, 1.0)
|
|
793
|
+
rescue ArgumentError, TypeError
|
|
794
|
+
0.0
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def normalize_letter_spacing(params)
|
|
798
|
+
Float(params[:letter_spacing] || params["letter_spacing"] || 0).clamp(0.0, 96.0)
|
|
799
|
+
rescue ArgumentError, TypeError
|
|
800
|
+
0.0
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
def normalize_waveform_height(params)
|
|
804
|
+
Float(params[:height] || params["height"] || 0.46).clamp(0.05, 1.1)
|
|
805
|
+
rescue ArgumentError, TypeError
|
|
806
|
+
0.46
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Tiny RGBA canvas with alpha blending and a few primitive drawing helpers.
|
|
810
|
+
class Canvas
|
|
811
|
+
def initialize(width:, height:)
|
|
812
|
+
@width = width
|
|
813
|
+
@height = height
|
|
814
|
+
@bytes = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
815
|
+
@bytes << ([0, 0, 0, 255].pack("C4") * (width * height))
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
attr_reader :width, :height, :bytes
|
|
819
|
+
|
|
820
|
+
def fill_gradient(top, bottom)
|
|
821
|
+
height.times do |y|
|
|
822
|
+
t = y.to_f / [height - 1, 1].max
|
|
823
|
+
color = 3.times.map { |index| interpolate(top[index], bottom[index], t).round }
|
|
824
|
+
width.times { |x| set_pixel(x, y, color, 255) }
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def draw_wave(y_base, amplitude:, color:, alpha:, height_scale: 1.0)
|
|
829
|
+
previous = nil
|
|
830
|
+
width.times do |x|
|
|
831
|
+
phase = (x.to_f / width) * Math::PI * 4.0
|
|
832
|
+
y = y_base + Math.sin(phase) * height * (0.06 + amplitude * 0.08) * height_scale
|
|
833
|
+
draw_line(previous[0], previous[1], x, y, color, alpha: alpha) if previous
|
|
834
|
+
previous = [x, y]
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def draw_rect_outline(x, y, rect_width, rect_height, color, alpha:)
|
|
839
|
+
draw_line(x, y, x + rect_width, y, color, alpha: alpha)
|
|
840
|
+
draw_line(x + rect_width, y, x + rect_width, y + rect_height, color, alpha: alpha)
|
|
841
|
+
draw_line(x + rect_width, y + rect_height, x, y + rect_height, color, alpha: alpha)
|
|
842
|
+
draw_line(x, y + rect_height, x, y, color, alpha: alpha)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
def draw_circle_outline(cx, cy, radius, color, alpha:)
|
|
846
|
+
segments = 96
|
|
847
|
+
previous = nil
|
|
848
|
+
(0..segments).each do |index|
|
|
849
|
+
angle = (index.to_f / segments) * Math::PI * 2
|
|
850
|
+
point = [cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius]
|
|
851
|
+
draw_line(previous[0], previous[1], point[0], point[1], color, alpha: alpha) if previous
|
|
852
|
+
previous = point
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
def fill_rect(x, y, rect_width, rect_height, color, alpha:)
|
|
857
|
+
start_x = x.round
|
|
858
|
+
end_x = (x + rect_width).round
|
|
859
|
+
start_y = y.round
|
|
860
|
+
end_y = (y + rect_height).round
|
|
861
|
+
start_y.upto(end_y) do |py|
|
|
862
|
+
start_x.upto(end_x) { |px| blend_pixel(px, py, color, alpha) }
|
|
863
|
+
end
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
def draw_line(x1, y1, x2, y2, color, alpha:)
|
|
867
|
+
x1 = x1.round
|
|
868
|
+
y1 = y1.round
|
|
869
|
+
x2 = x2.round
|
|
870
|
+
y2 = y2.round
|
|
871
|
+
steps = [(x2 - x1).abs, (y2 - y1).abs].max
|
|
872
|
+
return blend_pixel(x1, y1, color, alpha) if steps.zero?
|
|
873
|
+
|
|
874
|
+
steps.times do |step|
|
|
875
|
+
t = step.to_f / steps
|
|
876
|
+
blend_pixel(interpolate(x1, x2, t).round, interpolate(y1, y2, t).round, color, alpha)
|
|
877
|
+
end
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def fill_circle(cx, cy, radius, color, alpha:)
|
|
881
|
+
cx = cx.round
|
|
882
|
+
cy = cy.round
|
|
883
|
+
radius = radius.round
|
|
884
|
+
(cy - radius).upto(cy + radius) do |y|
|
|
885
|
+
(cx - radius).upto(cx + radius) do |x|
|
|
886
|
+
next if ((x - cx)**2) + ((y - cy)**2) > radius**2
|
|
887
|
+
|
|
888
|
+
blend_pixel(x, y, color, alpha)
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def draw_label(text, x:, y:, color:, alpha:, letter_spacing: 0.0)
|
|
894
|
+
chars = text.each_byte.first(24)
|
|
895
|
+
char_width = 14 + Float(letter_spacing).clamp(0.0, 96.0).round
|
|
896
|
+
total_width = chars.length * char_width
|
|
897
|
+
start_x = x.round - total_width / 2
|
|
898
|
+
chars.each_with_index do |byte, index|
|
|
899
|
+
height_factor = 0.35 + (byte % 9) * 0.07
|
|
900
|
+
fill_bar(start_x + index * char_width, y.round, 9, (42 * height_factor).round, color, alpha)
|
|
901
|
+
end
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
private
|
|
905
|
+
|
|
906
|
+
def fill_bar(x, baseline, bar_width, bar_height, color, alpha)
|
|
907
|
+
(baseline - bar_height).upto(baseline) do |y|
|
|
908
|
+
x.upto(x + bar_width) { |px| blend_pixel(px, y, color, alpha) }
|
|
909
|
+
end
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def blend_pixel(x, y, color, alpha)
|
|
913
|
+
return if x.negative? || y.negative? || x >= width || y >= height
|
|
914
|
+
|
|
915
|
+
offset = ((y * width) + x) * 4
|
|
916
|
+
amount = Float(alpha).clamp(0.0, 1.0)
|
|
917
|
+
3.times do |index|
|
|
918
|
+
current = bytes.getbyte(offset + index)
|
|
919
|
+
bytes.setbyte(offset + index, interpolate(current, color[index], amount).round)
|
|
920
|
+
end
|
|
921
|
+
bytes.setbyte(offset + 3, 255)
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def set_pixel(x, y, color, alpha)
|
|
925
|
+
offset = ((y * width) + x) * 4
|
|
926
|
+
bytes.setbyte(offset, color[0])
|
|
927
|
+
bytes.setbyte(offset + 1, color[1])
|
|
928
|
+
bytes.setbyte(offset + 2, color[2])
|
|
929
|
+
bytes.setbyte(offset + 3, alpha)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def interpolate(from, to, amount)
|
|
933
|
+
from + (to - from) * amount
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
end
|