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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +70 -117
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/playground-worker.js +373 -0
  5. data/docs/assets/playground.css +440 -0
  6. data/docs/assets/playground.js +652 -0
  7. data/docs/assets/site.css +744 -0
  8. data/docs/assets/vizcore-demo.gif +0 -0
  9. data/docs/assets/vizcore-poster.png +0 -0
  10. data/docs/assets/vj-tunnel.js +159 -0
  11. data/docs/index.html +225 -0
  12. data/docs/playground.html +81 -0
  13. data/docs/shape_dsl.md +269 -0
  14. data/examples/README.md +59 -0
  15. data/examples/assets/README.md +19 -0
  16. data/examples/audio_inspector.rb +34 -0
  17. data/examples/club_intro_drop.rb +78 -0
  18. data/examples/kansai_rubykaigi_visual.rb +70 -0
  19. data/examples/live_coding_minimal.rb +22 -0
  20. data/examples/midi_controller_show.rb +78 -0
  21. data/examples/midi_scene_switch.rb +3 -1
  22. data/examples/parser_visualizer.rb +48 -0
  23. data/examples/readme_demo.rb +17 -0
  24. data/examples/rhythm_geometry.rb +34 -0
  25. data/examples/ruby_crystal_show.rb +35 -0
  26. data/examples/shader_playground.rb +18 -0
  27. data/examples/unyo_liquid.rb +59 -0
  28. data/examples/vj_ambient_chill_room.rb +124 -0
  29. data/examples/vj_dnb_jungle.rb +170 -0
  30. data/examples/vj_festival_mainstage.rb +245 -0
  31. data/examples/vj_festival_mainstage.yml +17 -0
  32. data/examples/vj_glitch_industrial.rb +164 -0
  33. data/examples/vj_hiphop_cipher.rb +167 -0
  34. data/examples/vj_jpop_idol_live.rb +210 -0
  35. data/examples/vj_synthwave_retro.rb +173 -0
  36. data/examples/vj_techno_warehouse.rb +195 -0
  37. data/frontend/index.html +494 -2
  38. data/frontend/src/audio-inspector.js +40 -0
  39. data/frontend/src/custom-shape-param-controls.js +106 -0
  40. data/frontend/src/live-controls.js +131 -0
  41. data/frontend/src/main.js +1060 -16
  42. data/frontend/src/mapping-target-selector.js +109 -0
  43. data/frontend/src/midi-learn.js +194 -0
  44. data/frontend/src/performance-monitor.js +183 -0
  45. data/frontend/src/plugin-runtime.js +130 -0
  46. data/frontend/src/projector-mode.js +56 -0
  47. data/frontend/src/renderer/engine.js +157 -3
  48. data/frontend/src/renderer/layer-manager.js +442 -30
  49. data/frontend/src/renderer/shader-manager.js +26 -0
  50. data/frontend/src/runtime-control-preset.js +11 -0
  51. data/frontend/src/shader-error-overlay.js +29 -0
  52. data/frontend/src/shader-param-controls.js +93 -0
  53. data/frontend/src/shaders/builtins.js +380 -2
  54. data/frontend/src/shaders/post-effects.js +52 -0
  55. data/frontend/src/shape-editor-controls.js +157 -0
  56. data/frontend/src/visual-regression.js +67 -0
  57. data/frontend/src/visual-settings-preset.js +103 -0
  58. data/frontend/src/visuals/geometry.js +666 -0
  59. data/frontend/src/visuals/image-renderer.js +291 -0
  60. data/frontend/src/visuals/particle-system.js +56 -10
  61. data/frontend/src/visuals/shape-renderer.js +475 -0
  62. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  63. data/frontend/src/visuals/svg-arc.js +104 -0
  64. data/frontend/src/visuals/text-renderer.js +112 -11
  65. data/frontend/src/websocket-client.js +12 -1
  66. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  67. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  68. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  69. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  70. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  71. data/lib/vizcore/analysis/pipeline.rb +235 -11
  72. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  73. data/lib/vizcore/analysis.rb +4 -0
  74. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  75. data/lib/vizcore/audio/fixture_input.rb +65 -0
  76. data/lib/vizcore/audio/input_manager.rb +4 -2
  77. data/lib/vizcore/audio/mic_input.rb +24 -8
  78. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  79. data/lib/vizcore/audio.rb +1 -0
  80. data/lib/vizcore/cli/doctor.rb +159 -0
  81. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  82. data/lib/vizcore/cli/layer_docs.rb +46 -0
  83. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  84. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  85. data/lib/vizcore/cli/scene_validator.rb +337 -0
  86. data/lib/vizcore/cli/shader_template.rb +68 -0
  87. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  88. data/lib/vizcore/cli.rb +689 -18
  89. data/lib/vizcore/config.rb +103 -2
  90. data/lib/vizcore/control_preset.rb +68 -0
  91. data/lib/vizcore/dsl/engine.rb +277 -5
  92. data/lib/vizcore/dsl/layer_builder.rb +1280 -23
  93. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  94. data/lib/vizcore/dsl/mapping_resolver.rb +290 -7
  95. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  96. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  97. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  98. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  99. data/lib/vizcore/dsl/style_builder.rb +68 -0
  100. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  101. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  102. data/lib/vizcore/dsl.rb +5 -1
  103. data/lib/vizcore/layer_catalog.rb +275 -0
  104. data/lib/vizcore/project_manifest.rb +152 -0
  105. data/lib/vizcore/renderer/png_writer.rb +57 -0
  106. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  107. data/lib/vizcore/renderer/scene_frame_source.rb +132 -0
  108. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  109. data/lib/vizcore/renderer/snapshot.rb +38 -0
  110. data/lib/vizcore/renderer/snapshot_renderer.rb +938 -0
  111. data/lib/vizcore/renderer.rb +5 -0
  112. data/lib/vizcore/server/frame_broadcaster.rb +143 -8
  113. data/lib/vizcore/server/gallery_app.rb +155 -0
  114. data/lib/vizcore/server/gallery_page.rb +100 -0
  115. data/lib/vizcore/server/gallery_runner.rb +48 -0
  116. data/lib/vizcore/server/rack_app.rb +203 -4
  117. data/lib/vizcore/server/runner.rb +391 -22
  118. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  119. data/lib/vizcore/server/websocket_handler.rb +60 -10
  120. data/lib/vizcore/server.rb +4 -0
  121. data/lib/vizcore/shape.rb +719 -0
  122. data/lib/vizcore/sync/osc_message.rb +103 -0
  123. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  124. data/lib/vizcore/sync.rb +4 -0
  125. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  126. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  127. data/lib/vizcore/templates/plugin_readme.md +23 -0
  128. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  129. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  130. data/lib/vizcore/templates/project_readme.md +7 -23
  131. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  132. data/lib/vizcore/version.rb +1 -1
  133. data/lib/vizcore.rb +28 -0
  134. data/scripts/browser_capture.mjs +75 -0
  135. data/sig/vizcore.rbs +461 -0
  136. metadata +94 -3
  137. 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