vizcore 1.0.0 → 1.2.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -648
  3. data/docs/assets/playground-worker.js +373 -0
  4. data/docs/assets/playground.css +440 -0
  5. data/docs/assets/playground.js +652 -0
  6. data/docs/index.html +2 -1
  7. data/docs/playground.html +81 -0
  8. data/docs/shape_dsl.md +269 -0
  9. data/frontend/index.html +50 -2
  10. data/frontend/src/audio-inspector.js +9 -0
  11. data/frontend/src/custom-shape-param-controls.js +106 -0
  12. data/frontend/src/live-controls.js +219 -7
  13. data/frontend/src/main.js +703 -45
  14. data/frontend/src/mapping-target-selector.js +109 -0
  15. data/frontend/src/midi-learn.js +22 -2
  16. data/frontend/src/performance-monitor.js +137 -1
  17. data/frontend/src/renderer/engine.js +401 -11
  18. data/frontend/src/renderer/layer-manager.js +490 -75
  19. data/frontend/src/runtime-control-preset.js +44 -0
  20. data/frontend/src/scene-patches.js +159 -0
  21. data/frontend/src/shader-error-overlay.js +1 -0
  22. data/frontend/src/shape-editor-controls.js +157 -0
  23. data/frontend/src/visuals/geometry.js +425 -27
  24. data/frontend/src/visuals/image-renderer.js +19 -0
  25. data/frontend/src/visuals/particle-system.js +10 -0
  26. data/frontend/src/visuals/shape-renderer.js +488 -0
  27. data/frontend/src/visuals/spectrogram-renderer.js +14 -0
  28. data/frontend/src/visuals/svg-arc.js +104 -0
  29. data/frontend/src/visuals/text-renderer.js +13 -0
  30. data/frontend/src/websocket-client.js +6 -0
  31. data/lib/vizcore/analysis/adaptive_normalizer.rb +20 -2
  32. data/lib/vizcore/analysis/bpm_estimator.rb +18 -8
  33. data/lib/vizcore/analysis/feature_recorder.rb +117 -7
  34. data/lib/vizcore/analysis/feature_replay.rb +48 -9
  35. data/lib/vizcore/analysis/pipeline.rb +258 -9
  36. data/lib/vizcore/analysis/tap_tempo.rb +17 -2
  37. data/lib/vizcore/audio/calibration.rb +156 -0
  38. data/lib/vizcore/audio/file_input.rb +28 -0
  39. data/lib/vizcore/audio/input_manager.rb +36 -1
  40. data/lib/vizcore/audio/midi_input.rb +5 -0
  41. data/lib/vizcore/audio/ring_buffer.rb +22 -0
  42. data/lib/vizcore/audio.rb +1 -0
  43. data/lib/vizcore/cli/dsl_reference.rb +65 -9
  44. data/lib/vizcore/cli/plugin_checker.rb +93 -0
  45. data/lib/vizcore/cli/scene_diagnostics.rb +2 -2
  46. data/lib/vizcore/cli/scene_inspector.rb +35 -1
  47. data/lib/vizcore/cli/scene_validator.rb +573 -33
  48. data/lib/vizcore/cli/shader_template.rb +7 -2
  49. data/lib/vizcore/cli/shader_uniform_docs.rb +11 -0
  50. data/lib/vizcore/cli.rb +268 -15
  51. data/lib/vizcore/config.rb +40 -3
  52. data/lib/vizcore/control_preset.rb +29 -0
  53. data/lib/vizcore/deep_copy.rb +21 -0
  54. data/lib/vizcore/dsl/color_helpers.rb +155 -0
  55. data/lib/vizcore/dsl/engine.rb +219 -23
  56. data/lib/vizcore/dsl/layer_builder.rb +1072 -21
  57. data/lib/vizcore/dsl/layer_group_builder.rb +10 -12
  58. data/lib/vizcore/dsl/layout_helpers.rb +290 -0
  59. data/lib/vizcore/dsl/mapping_preset_builder.rb +41 -0
  60. data/lib/vizcore/dsl/mapping_resolver.rb +549 -13
  61. data/lib/vizcore/dsl/mapping_transform_builder.rb +50 -0
  62. data/lib/vizcore/dsl/midi_map_executor.rb +219 -23
  63. data/lib/vizcore/dsl/reaction_builder.rb +1 -0
  64. data/lib/vizcore/dsl/scene_builder.rb +83 -13
  65. data/lib/vizcore/dsl/shader_source_resolver.rb +1 -10
  66. data/lib/vizcore/dsl/style_builder.rb +3 -0
  67. data/lib/vizcore/dsl/timeline_builder.rb +91 -8
  68. data/lib/vizcore/dsl/transition_controller.rb +157 -18
  69. data/lib/vizcore/dsl.rb +2 -0
  70. data/lib/vizcore/layer_catalog.rb +5 -2
  71. data/lib/vizcore/plugin_asset_policy.rb +55 -0
  72. data/lib/vizcore/project_manifest.rb +12 -2
  73. data/lib/vizcore/renderer/render_sequence.rb +104 -13
  74. data/lib/vizcore/renderer/scene_frame_source.rb +190 -12
  75. data/lib/vizcore/renderer/scene_serializer.rb +38 -0
  76. data/lib/vizcore/renderer/snapshot.rb +4 -3
  77. data/lib/vizcore/renderer/snapshot_renderer.rb +641 -23
  78. data/lib/vizcore/scene_trust.rb +31 -0
  79. data/lib/vizcore/server/frame_broadcaster.rb +513 -18
  80. data/lib/vizcore/server/rack_app.rb +151 -4
  81. data/lib/vizcore/server/runner.rb +697 -82
  82. data/lib/vizcore/server/websocket_handler.rb +236 -14
  83. data/lib/vizcore/server.rb +21 -0
  84. data/lib/vizcore/shape.rb +742 -0
  85. data/lib/vizcore/sync/osc_message.rb +66 -9
  86. data/lib/vizcore/version.rb +1 -1
  87. data/lib/vizcore.rb +34 -0
  88. data/scripts/browser_capture.mjs +31 -2
  89. data/sig/vizcore.rbs +154 -4
  90. metadata +29 -3
@@ -8,6 +8,9 @@ module Vizcore
8
8
  class SnapshotRenderer
9
9
  DEFAULT_WIDTH = 1280
10
10
  DEFAULT_HEIGHT = 720
11
+ PATH_DEFAULT_MAX_SEGMENTS = 4096
12
+ PATH_HARD_MAX_SEGMENTS = 65_536
13
+ PATH_MAX_RECURSION = 12
11
14
  PALETTE = [
12
15
  [56, 189, 248],
13
16
  [225, 29, 72],
@@ -16,9 +19,10 @@ module Vizcore
16
19
  [250, 204, 21]
17
20
  ].freeze
18
21
 
19
- def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT)
22
+ def initialize(width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, transparent: false)
20
23
  @width = normalize_dimension(width)
21
24
  @height = normalize_dimension(height)
25
+ @transparent = !!transparent
22
26
  end
23
27
 
24
28
  attr_reader :width, :height
@@ -27,8 +31,8 @@ module Vizcore
27
31
  # @param audio [Hash]
28
32
  # @return [String] PNG bytes
29
33
  def render(scene:, audio:)
30
- canvas = Canvas.new(width: width, height: height)
31
- canvas.fill_gradient(background_top(audio), background_bottom(audio))
34
+ canvas = Canvas.new(width: width, height: height, transparent: @transparent)
35
+ canvas.fill_gradient(background_top(audio), background_bottom(audio)) unless @transparent
32
36
  layers = Array(scene[:layers] || scene["layers"])
33
37
  layers = [default_layer] if layers.empty?
34
38
  layers.each_with_index { |layer, index| render_layer(canvas, layer, audio, index) }
@@ -156,6 +160,7 @@ module Vizcore
156
160
  def render_shape_layer(canvas, layer, audio, color)
157
161
  params = Hash(layer[:params] || layer["params"] || {})
158
162
  shapes = Array(params[:shapes] || params["shapes"])
163
+ context = shape_coordinate_context(params)
159
164
  pulse = clamp(audio[:beat_pulse])
160
165
  alpha = 0.58 + pulse * 0.24
161
166
 
@@ -163,34 +168,522 @@ module Vizcore
163
168
  shape_hash = Hash(shape)
164
169
  case (shape_hash[:kind] || shape_hash["kind"]).to_s
165
170
  when "circle"
166
- render_circle_shape(canvas, shape_hash, color, alpha)
171
+ render_circle_shape(canvas, shape_hash, color, alpha, context)
167
172
  when "line"
168
- render_line_shape(canvas, shape_hash, color, alpha)
173
+ render_line_shape(canvas, shape_hash, color, alpha, context)
174
+ when "rect"
175
+ render_rect_shape(canvas, shape_hash, color, alpha, context)
176
+ when "polygon", "polyline"
177
+ render_polygon_shape(canvas, shape_hash, color, alpha, context)
178
+ when "path"
179
+ render_path_shape(canvas, shape_hash, color, alpha, context)
180
+ when "star"
181
+ render_star_shape(canvas, shape_hash, color, alpha, context)
169
182
  end
170
183
  end
171
184
  rescue ArgumentError, TypeError
172
185
  nil
173
186
  end
174
187
 
175
- def render_circle_shape(canvas, shape, color, alpha)
188
+ def render_circle_shape(canvas, shape, color, alpha, context)
176
189
  count = [[Integer(shape[:count] || shape["count"] || 1), 1].max, 32].min
177
- radius = Float(shape[:radius] || shape["radius"] || 100).abs
178
- x = Float(shape[:x] || shape["x"] || width * 0.5)
179
- y = Float(shape[:y] || shape["y"] || height * 0.5)
180
- x = width * 0.5 if x.abs <= 1.5
181
- y = height * 0.5 if y.abs <= 1.5
190
+ radius = shape_length(shape[:radius] || shape["radius"] || 100, context, :radius)
191
+ center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
182
192
 
183
193
  count.times do |index|
184
- canvas.draw_circle_outline(x, y, radius * ((index + 1).to_f / count), color, alpha: alpha)
194
+ ring_radius = radius * ((index + 1).to_f / count)
195
+ render_polyline_shape(canvas, circle_points(center, ring_radius), shape, color, alpha, context, closed: true)
185
196
  end
186
197
  end
187
198
 
188
- def render_line_shape(canvas, shape, color, alpha)
189
- x1 = Float(shape[:x1] || shape["x1"] || width * 0.2)
190
- y1 = Float(shape[:y1] || shape["y1"] || height * 0.5)
191
- x2 = Float(shape[:x2] || shape["x2"] || width * 0.8)
192
- y2 = Float(shape[:y2] || shape["y2"] || height * 0.5)
193
- canvas.draw_line(x1, y1, x2, y2, color, alpha: alpha)
199
+ def render_line_shape(canvas, shape, color, alpha, context)
200
+ defaults = context[:units] == :legacy || context[:units] == :ndc ? [-0.8, 0, 0.8, 0] : [-100, 0, 100, 0]
201
+ from = shape_point(shape[:x1] || shape["x1"] || defaults[0], shape[:y1] || shape["y1"] || defaults[1], context)
202
+ to = shape_point(shape[:x2] || shape["x2"] || defaults[2], shape[:y2] || shape["y2"] || defaults[3], context)
203
+ draw_shape_segment(canvas, from, to, shape, color, alpha, context)
204
+ end
205
+
206
+ def render_rect_shape(canvas, shape, color, alpha, context)
207
+ center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
208
+ half_width = shape_length(shape[:width] || shape["width"] || 100, context, :x) / 2.0
209
+ half_height = shape_length(shape[:height] || shape["height"] || 100, context, :y) / 2.0
210
+ points = [
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
+ [center[0] - half_width, center[1] + half_height]
215
+ ]
216
+ render_polyline_shape(canvas, points, shape, color, alpha, context, closed: true)
217
+ end
218
+
219
+ def render_polygon_shape(canvas, shape, color, alpha, context)
220
+ points = Array(shape[:points] || shape["points"]).filter_map do |point|
221
+ values = Array(point)
222
+ next if values.length < 2
223
+
224
+ shape_point(values[0], values[1], context)
225
+ end
226
+ closed = (shape[:kind] || shape["kind"]).to_s == "polygon" ? shape.fetch(:closed, shape.fetch("closed", true)) : false
227
+ render_polyline_shape(canvas, points, shape, color, alpha, context, closed: closed)
228
+ end
229
+
230
+ def render_star_shape(canvas, shape, color, alpha, context)
231
+ tips = [[Integer(shape[:points] || shape["points"] || 5), 3].max, 128].min
232
+ center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
233
+ radius = shape_length(shape[:radius] || shape["radius"] || 100, context, :radius)
234
+ inner_radius = shape_length(shape[:inner_radius] || shape["inner_radius"] || Float(shape[:radius] || shape["radius"] || 100) * 0.5, context, :radius)
235
+ rotation = Float(shape[:rotation] || shape["rotation"] || -90) * Math::PI / 180.0
236
+ points = (tips * 2).times.map do |index|
237
+ angle = rotation + (index.to_f / (tips * 2)) * Math::PI * 2
238
+ point_radius = index.even? ? radius : inner_radius
239
+ [center[0] + Math.cos(angle) * point_radius, center[1] - Math.sin(angle) * point_radius]
240
+ end
241
+ render_polyline_shape(canvas, points, shape, color, alpha, context, closed: true)
242
+ end
243
+
244
+ def render_path_shape(canvas, shape, color, alpha, context)
245
+ detail = [[Integer(shape[:detail] || shape["detail"] || 32), 4].max, 128].min
246
+ tolerance = path_tolerance(shape)
247
+ segment_budget = { remaining: path_segment_limit(shape) }
248
+ current = nil
249
+ subpath_start = nil
250
+ Array(shape[:commands] || shape["commands"]).each do |entry|
251
+ command, *values = Array(entry)
252
+ values = values.map { |value| Float(value) }
253
+ case command.to_s.upcase
254
+ when "M"
255
+ current = values.first(2)
256
+ subpath_start = current
257
+ when "L"
258
+ next unless current && values.length >= 2
259
+
260
+ current = draw_raw_path_segment(canvas, current, values.first(2), shape, color, alpha, context, segment_budget)
261
+ when "H"
262
+ next unless current && values.length >= 1
263
+
264
+ current = draw_raw_path_segment(canvas, current, [values[0], current[1]], shape, color, alpha, context, segment_budget)
265
+ when "V"
266
+ next unless current && values.length >= 1
267
+
268
+ current = draw_raw_path_segment(canvas, current, [current[0], values[0]], shape, color, alpha, context, segment_budget)
269
+ when "Q"
270
+ next unless current && values.length >= 4
271
+
272
+ current = draw_quadratic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget)
273
+ when "C"
274
+ next unless current && values.length >= 6
275
+
276
+ current = draw_cubic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget)
277
+ when "A"
278
+ next unless current && values.length >= 7
279
+
280
+ current = draw_arc_path(canvas, current, values, detail, shape, color, alpha, context, segment_budget)
281
+ when "Z"
282
+ if current && subpath_start
283
+ current = draw_raw_path_segment(canvas, current, subpath_start, shape, color, alpha, context, segment_budget)
284
+ end
285
+ end
286
+ end
287
+ end
288
+
289
+ def render_polyline_shape(canvas, points, shape, color, alpha, context, closed:)
290
+ return if points.length < 2
291
+
292
+ points.each_cons(2) { |from, to| draw_shape_segment(canvas, from, to, shape, color, alpha, context) }
293
+ draw_shape_segment(canvas, points.last, points.first, shape, color, alpha, context) if closed && points.length > 2
294
+ end
295
+
296
+ def draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget = nil)
297
+ return to if segment_budget && segment_budget[:remaining] <= 0
298
+
299
+ draw_shape_segment(canvas, shape_point(from[0], from[1], context), shape_point(to[0], to[1], context), shape, color, alpha, context)
300
+ segment_budget[:remaining] -= 1 if segment_budget
301
+ to
302
+ end
303
+
304
+ def draw_quadratic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget = nil)
305
+ previous = current
306
+ control = values.first(2)
307
+ endpoint = values.last(2)
308
+ if tolerance
309
+ draw_adaptive_quadratic_path(canvas, current, control, endpoint, tolerance, shape, color, alpha, context, segment_budget)
310
+ return endpoint
311
+ end
312
+
313
+ 1.upto(detail) do |step|
314
+ break if segment_budget && segment_budget[:remaining] <= 0
315
+
316
+ t = step.to_f / detail
317
+ point = [
318
+ quadratic_point(current[0], control[0], endpoint[0], t),
319
+ quadratic_point(current[1], control[1], endpoint[1], t)
320
+ ]
321
+ draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
322
+ previous = point
323
+ end
324
+ endpoint
325
+ end
326
+
327
+ def draw_cubic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget = nil)
328
+ previous = current
329
+ c1 = values[0, 2]
330
+ c2 = values[2, 2]
331
+ endpoint = values[4, 2]
332
+ if tolerance
333
+ draw_adaptive_cubic_path(canvas, current, c1, c2, endpoint, tolerance, shape, color, alpha, context, segment_budget)
334
+ return endpoint
335
+ end
336
+
337
+ 1.upto(detail) do |step|
338
+ break if segment_budget && segment_budget[:remaining] <= 0
339
+
340
+ t = step.to_f / detail
341
+ point = [
342
+ cubic_point(current[0], c1[0], c2[0], endpoint[0], t),
343
+ cubic_point(current[1], c1[1], c2[1], endpoint[1], t)
344
+ ]
345
+ draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
346
+ previous = point
347
+ end
348
+ endpoint
349
+ end
350
+
351
+ def draw_arc_path(canvas, current, values, detail, shape, color, alpha, context, segment_budget = nil)
352
+ endpoint = values[5, 2]
353
+ arc = svg_arc_description(
354
+ from: current,
355
+ to: endpoint,
356
+ rx: values[0],
357
+ ry: values[1],
358
+ x_axis_rotation: values[2],
359
+ large_arc: arc_flag(values[3]),
360
+ sweep: arc_flag(values[4])
361
+ )
362
+ unless arc
363
+ return draw_raw_path_segment(canvas, current, endpoint, shape, color, alpha, context, segment_budget)
364
+ end
365
+
366
+ previous = current
367
+ segments = svg_arc_segment_count(arc, detail)
368
+ 1.upto(segments) do |step|
369
+ break if segment_budget && segment_budget[:remaining] <= 0
370
+
371
+ point = svg_arc_point(arc, step.to_f / segments)
372
+ draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
373
+ previous = point
374
+ end
375
+ endpoint
376
+ end
377
+
378
+ def path_segment_limit(shape)
379
+ raw_value = shape[:max_segments] || shape["max_segments"] || PATH_DEFAULT_MAX_SEGMENTS
380
+ [[Integer(raw_value), 1].max, PATH_HARD_MAX_SEGMENTS].min
381
+ rescue ArgumentError, TypeError
382
+ PATH_DEFAULT_MAX_SEGMENTS
383
+ end
384
+
385
+ def path_tolerance(shape)
386
+ return unless shape.key?(:tolerance) || shape.key?("tolerance")
387
+
388
+ value = Float(shape[:tolerance] || shape["tolerance"])
389
+ value if value.finite? && value >= 0
390
+ rescue ArgumentError, TypeError
391
+ nil
392
+ end
393
+
394
+ def draw_adaptive_quadratic_path(canvas, from, control, to, tolerance, shape, color, alpha, context, segment_budget, depth = 0)
395
+ return if segment_budget && segment_budget[:remaining] <= 0
396
+
397
+ if depth >= PATH_MAX_RECURSION || point_line_distance(control, from, to) <= tolerance
398
+ draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget)
399
+ return
400
+ end
401
+
402
+ left_control = midpoint(from, control)
403
+ right_control = midpoint(control, to)
404
+ center = midpoint(left_control, right_control)
405
+ draw_adaptive_quadratic_path(canvas, from, left_control, center, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
406
+ draw_adaptive_quadratic_path(canvas, center, right_control, to, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
407
+ end
408
+
409
+ def draw_adaptive_cubic_path(canvas, from, c1, c2, to, tolerance, shape, color, alpha, context, segment_budget, depth = 0)
410
+ return if segment_budget && segment_budget[:remaining] <= 0
411
+
412
+ flatness = [point_line_distance(c1, from, to), point_line_distance(c2, from, to)].max
413
+ if depth >= PATH_MAX_RECURSION || flatness <= tolerance
414
+ draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget)
415
+ return
416
+ end
417
+
418
+ p01 = midpoint(from, c1)
419
+ p12 = midpoint(c1, c2)
420
+ p23 = midpoint(c2, to)
421
+ p012 = midpoint(p01, p12)
422
+ p123 = midpoint(p12, p23)
423
+ center = midpoint(p012, p123)
424
+ draw_adaptive_cubic_path(canvas, from, p01, p012, center, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
425
+ draw_adaptive_cubic_path(canvas, center, p123, p23, to, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
426
+ end
427
+
428
+ def midpoint(from, to)
429
+ [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]
430
+ end
431
+
432
+ def point_line_distance(point, from, to)
433
+ dx = to[0] - from[0]
434
+ dy = to[1] - from[1]
435
+ length = Math.sqrt((dx * dx) + (dy * dy))
436
+ return Math.sqrt(((point[0] - from[0])**2) + ((point[1] - from[1])**2)) if length <= 0
437
+
438
+ ((dy * point[0]) - (dx * point[1]) + (to[0] * from[1]) - (to[1] * from[0])).abs / length
439
+ end
440
+
441
+ def draw_shape_segment(canvas, from, to, shape, color, alpha, context)
442
+ from = apply_shape_transform(from, shape, context)
443
+ to = apply_shape_transform(to, shape, context)
444
+ canvas.draw_line(from[0], from[1], to[0], to[1], color, alpha: alpha * shape_opacity(shape))
445
+ end
446
+
447
+ def shape_coordinate_context(params)
448
+ units = (params[:units] || params["units"]).to_s.strip.downcase
449
+ version = Integer(params[:shape_schema_version] || params["shape_schema_version"] || 1)
450
+ { units: (units.empty? ? (version >= 2 ? :logical : :legacy) : units.to_sym) }
451
+ rescue ArgumentError, TypeError
452
+ { units: :legacy }
453
+ end
454
+
455
+ def shape_point(x, y, context)
456
+ [shape_coordinate(x, context, :x), shape_coordinate(y, context, :y)]
457
+ end
458
+
459
+ def shape_coordinate(value, context, axis)
460
+ numeric = Float(value || 0)
461
+ case context[:units]
462
+ when :ndc
463
+ axis == :x ? width * 0.5 + numeric * width * 0.5 : height * 0.5 - numeric * height * 0.5
464
+ when :logical, :center, :center_origin, :px
465
+ axis == :x ? width * 0.5 + numeric : height * 0.5 - numeric
466
+ when :screen, :canvas, :viewport
467
+ numeric
468
+ else
469
+ legacy_shape_coordinate(numeric, axis)
470
+ end
471
+ end
472
+
473
+ def legacy_shape_coordinate(value, axis)
474
+ return axis == :x ? width * 0.5 + value * width * 0.5 : height * 0.5 - value * height * 0.5 if value.abs <= 1.5
475
+
476
+ value
477
+ end
478
+
479
+ def shape_length(value, context, _axis)
480
+ numeric = Float(value || 0).abs
481
+ return numeric * [width, height].min * 0.5 if context[:units] == :ndc || numeric <= 2
482
+
483
+ numeric
484
+ end
485
+
486
+ def circle_points(center, radius)
487
+ segments = 96
488
+ segments.times.map do |index|
489
+ angle = (index.to_f / segments) * Math::PI * 2
490
+ [center[0] + Math.cos(angle) * radius, center[1] + Math.sin(angle) * radius]
491
+ end
492
+ end
493
+
494
+ def apply_shape_transform(point, shape, context)
495
+ transform = shape_transform(shape, context)
496
+ shifted_x = (point[0] - transform[:origin][0]) * transform[:scale][:x]
497
+ shifted_y = (point[1] - transform[:origin][1]) * transform[:scale][:y]
498
+ radians = -transform[:rotate] * Math::PI / 180.0
499
+ cos = Math.cos(radians)
500
+ sin = Math.sin(radians)
501
+ rotated_x = shifted_x * cos - shifted_y * sin
502
+ rotated_y = shifted_x * sin + shifted_y * cos
503
+
504
+ [
505
+ rotated_x + transform[:origin][0] + transform[:translate][:x],
506
+ rotated_y + transform[:origin][1] + transform[:translate][:y]
507
+ ]
508
+ end
509
+
510
+ def shape_transform(shape, context)
511
+ transform = Hash(shape[:transform] || shape["transform"] || {})
512
+ {
513
+ translate: shape_vector_pair(shape_hash_value(transform, :translate) || shape_hash_value(shape, :translate), context),
514
+ origin: shape_origin_pair(shape_hash_value(transform, :origin) || shape_hash_value(shape, :origin), context),
515
+ rotate: Float(shape_hash_value(transform, :rotate) || shape_hash_value(shape, :rotate) || shape_hash_value(shape, :rotation) || 0),
516
+ scale: shape_scale(shape_hash_value(transform, :scale) || shape_hash_value(shape, :scale))
517
+ }
518
+ end
519
+
520
+ def shape_vector_pair(value, context)
521
+ if value.is_a?(Array)
522
+ return { x: shape_vector(value[0], context, :x), y: shape_vector(value[1], context, :y) }
523
+ end
524
+
525
+ values = value.is_a?(Hash) ? value : {}
526
+ { x: shape_vector(shape_hash_value(values, :x) || 0, context, :x), y: shape_vector(shape_hash_value(values, :y) || 0, context, :y) }
527
+ end
528
+
529
+ def shape_origin_pair(value, context)
530
+ if value.is_a?(Array)
531
+ return shape_point(value[0], value[1], context)
532
+ end
533
+
534
+ values = value.is_a?(Hash) ? value : {}
535
+ shape_point(shape_hash_value(values, :x) || 0, shape_hash_value(values, :y) || 0, context)
536
+ end
537
+
538
+ def shape_vector(value, context, axis)
539
+ numeric = Float(value || 0)
540
+ case context[:units]
541
+ when :ndc
542
+ axis == :x ? numeric * width * 0.5 : -numeric * height * 0.5
543
+ else
544
+ axis == :x ? numeric : -numeric
545
+ end
546
+ end
547
+
548
+ def shape_scale(value)
549
+ if value.is_a?(Hash)
550
+ return {
551
+ x: Float(shape_hash_value(value, :x) || 1).clamp(-8.0, 8.0),
552
+ y: Float(shape_hash_value(value, :y) || 1).clamp(-8.0, 8.0)
553
+ }
554
+ end
555
+
556
+ scale = Float(value || 1).clamp(-8.0, 8.0)
557
+ { x: scale, y: scale }
558
+ end
559
+
560
+ def shape_opacity(shape)
561
+ Float(shape[:opacity] || shape["opacity"] || 1).clamp(0.0, 1.0)
562
+ rescue ArgumentError, TypeError
563
+ 1.0
564
+ end
565
+
566
+ def shape_hash_value(hash, key)
567
+ hash[key] || hash[key.to_s]
568
+ end
569
+
570
+ def quadratic_point(from, control, to, t)
571
+ inv = 1.0 - t
572
+ inv * inv * from + 2 * inv * t * control + t * t * to
573
+ end
574
+
575
+ def cubic_point(from, c1, c2, to, t)
576
+ inv = 1.0 - t
577
+ inv * inv * inv * from + 3 * inv * inv * t * c1 + 3 * inv * t * t * c2 + t * t * t * to
578
+ end
579
+
580
+ def svg_arc_description(from:, to:, rx:, ry:, x_axis_rotation:, large_arc:, sweep:)
581
+ return if same_point?(from, to)
582
+
583
+ radius_x = Float(rx || 0).abs
584
+ radius_y = Float(ry || 0).abs
585
+ return if radius_x <= 0 || radius_y <= 0
586
+
587
+ rotation = Float(x_axis_rotation || 0) * Math::PI / 180.0
588
+ cos = Math.cos(rotation)
589
+ sin = Math.sin(rotation)
590
+ dx = (from[0] - to[0]) / 2.0
591
+ dy = (from[1] - to[1]) / 2.0
592
+ x1p = cos * dx + sin * dy
593
+ y1p = -sin * dx + cos * dy
594
+
595
+ scale = (x1p * x1p / (radius_x * radius_x)) + (y1p * y1p / (radius_y * radius_y))
596
+ if scale > 1
597
+ multiplier = Math.sqrt(scale)
598
+ radius_x *= multiplier
599
+ radius_y *= multiplier
600
+ end
601
+
602
+ center = svg_arc_center(
603
+ from: from,
604
+ to: to,
605
+ radius_x: radius_x,
606
+ radius_y: radius_y,
607
+ x1p: x1p,
608
+ y1p: y1p,
609
+ rotation_cos: cos,
610
+ rotation_sin: sin,
611
+ large_arc: large_arc,
612
+ sweep: sweep
613
+ )
614
+ return unless center
615
+
616
+ start_vector = [(x1p - center[:cxp]) / radius_x, (y1p - center[:cyp]) / radius_y]
617
+ end_vector = [(-x1p - center[:cxp]) / radius_x, (-y1p - center[:cyp]) / radius_y]
618
+ start_angle = vector_angle([1.0, 0.0], start_vector)
619
+ delta_angle = vector_angle(start_vector, end_vector)
620
+ delta_angle -= Math::PI * 2 if !sweep && delta_angle.positive?
621
+ delta_angle += Math::PI * 2 if sweep && delta_angle.negative?
622
+
623
+ {
624
+ cx: center[:cx],
625
+ cy: center[:cy],
626
+ rx: radius_x,
627
+ ry: radius_y,
628
+ rotation: rotation,
629
+ start_angle: start_angle,
630
+ delta_angle: delta_angle
631
+ }
632
+ rescue ArgumentError, TypeError
633
+ nil
634
+ end
635
+
636
+ def svg_arc_center(from:, to:, radius_x:, radius_y:, x1p:, y1p:, rotation_cos:, rotation_sin:, large_arc:, sweep:)
637
+ rx2 = radius_x * radius_x
638
+ ry2 = radius_y * radius_y
639
+ x1p2 = x1p * x1p
640
+ y1p2 = y1p * y1p
641
+ denominator = rx2 * y1p2 + ry2 * x1p2
642
+ return if denominator.zero?
643
+
644
+ numerator = [rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2, 0.0].max
645
+ sign = large_arc == sweep ? -1.0 : 1.0
646
+ coefficient = sign * Math.sqrt(numerator / denominator)
647
+ cxp = coefficient * ((radius_x * y1p) / radius_y)
648
+ cyp = coefficient * (-(radius_y * x1p) / radius_x)
649
+ {
650
+ cxp: cxp,
651
+ cyp: cyp,
652
+ cx: rotation_cos * cxp - rotation_sin * cyp + (from[0] + to[0]) / 2.0,
653
+ cy: rotation_sin * cxp + rotation_cos * cyp + (from[1] + to[1]) / 2.0
654
+ }
655
+ end
656
+
657
+ def svg_arc_point(arc, progress)
658
+ angle = arc[:start_angle] + arc[:delta_angle] * progress
659
+ cos_rotation = Math.cos(arc[:rotation])
660
+ sin_rotation = Math.sin(arc[:rotation])
661
+ x = Math.cos(angle) * arc[:rx]
662
+ y = Math.sin(angle) * arc[:ry]
663
+ [
664
+ arc[:cx] + cos_rotation * x - sin_rotation * y,
665
+ arc[:cy] + sin_rotation * x + cos_rotation * y
666
+ ]
667
+ end
668
+
669
+ def svg_arc_segment_count(arc, detail)
670
+ [((arc[:delta_angle].abs / (Math::PI * 2)) * detail).ceil, 1].max
671
+ end
672
+
673
+ def vector_angle(from, to)
674
+ cross = from[0] * to[1] - from[1] * to[0]
675
+ dot = from[0] * to[0] + from[1] * to[1]
676
+ Math.atan2(cross, dot)
677
+ end
678
+
679
+ def same_point?(from, to)
680
+ (from[0] - to[0]).abs < 1e-9 && (from[1] - to[1]).abs < 1e-9
681
+ end
682
+
683
+ def arc_flag(value)
684
+ !Float(value || 0).zero?
685
+ rescue ArgumentError, TypeError
686
+ false
194
687
  end
195
688
 
196
689
  def render_mesh_layer(canvas, layer, audio, color, index)
@@ -267,14 +760,115 @@ module Vizcore
267
760
  end
268
761
 
269
762
  def configured_color(params)
270
- [params[:color], params["color"]].map { |value| value.to_s.strip }.find { |value| !value.empty? }
763
+ value = params[:color]
764
+ value = params["color"] unless value
765
+ resolved = resolve_color_value(value)
766
+ return resolved.to_s.strip unless resolved.to_s.strip.empty?
767
+
768
+ nil
769
+ end
770
+
771
+ def resolve_color_value(value)
772
+ return value if value.is_a?(String)
773
+ return resolve_gradient_color(value) if value.is_a?(Hash)
774
+
775
+ value
776
+ end
777
+
778
+ def resolve_gradient_color(value)
779
+ gradient = value[:gradient] || value["gradient"]
780
+ return nil unless gradient.is_a?(Hash)
781
+
782
+ colors = normalize_colors(gradient[:colors] || gradient["colors"])
783
+ return nil if colors.empty?
784
+
785
+ return colors[0] if colors.length == 1
786
+
787
+ position = normalize_position(gradient[:position] || gradient["position"])
788
+ stops = normalize_gradient_stops(gradient[:stops] || gradient["stops"], colors.length)
789
+
790
+ if stops
791
+ resolve_gradient_color_with_stops(colors, position, stops)
792
+ else
793
+ resolve_gradient_color_with_position(colors, position)
794
+ end
795
+ end
796
+
797
+ def normalize_gradient_stops(stops, color_count)
798
+ return nil unless stops
799
+
800
+ values = Array(stops).filter_map { |entry| Float(entry, exception: false) }
801
+ return nil if values.length != color_count
802
+
803
+ values.sort.map { |value| value.to_f.clamp(0.0, 1.0) }
804
+ end
805
+
806
+ def resolve_gradient_color_with_position(colors, position)
807
+ segment_length = 1.0 / (colors.length - 1)
808
+ segment = [(position / segment_length).floor, colors.length - 2].min
809
+ blend = (position % segment_length) / segment_length
810
+
811
+ left_color = parse_hex_color(colors[segment])
812
+ right_color = parse_hex_color(colors[segment + 1])
813
+ return colors[segment] if left_color.nil? || right_color.nil?
814
+
815
+ interpolate_hex_color(left_color, right_color, blend)
816
+ end
817
+
818
+ def resolve_gradient_color_with_stops(colors, position, stops)
819
+ index = Array.new(colors.length - 1) { |offset| offset }.index do |offset|
820
+ position <= stops[offset + 1]
821
+ end
822
+
823
+ return colors.last if index.nil?
824
+
825
+ return colors[0] if index == 0 && position <= stops[0]
826
+
827
+ left_index = [index, colors.length - 2].min
828
+ right_index = left_index + 1
829
+ start = stops[left_index]
830
+ stop = stops[right_index]
831
+ blend = ((position - start) / (stop - start)).clamp(0.0, 1.0)
832
+
833
+ left_color = parse_hex_color(colors[left_index])
834
+ right_color = parse_hex_color(colors[right_index])
835
+ return colors[left_index] if left_color.nil? || right_color.nil?
836
+
837
+ interpolate_hex_color(left_color, right_color, blend)
838
+ end
839
+
840
+ def normalize_colors(value)
841
+ Array(value).map { |entry| entry.to_s.strip }.reject(&:empty?)
842
+ end
843
+
844
+ def normalize_position(value)
845
+ position = Float(value)
846
+ position = 0.0 unless position.finite?
847
+
848
+ position % 1.0
849
+ rescue ArgumentError, TypeError
850
+ 0.0
271
851
  end
272
852
 
273
853
  def palette_color(params, index)
274
854
  palette = Array(params[:palette] || params["palette"]).map { |color| color.to_s.strip }.reject(&:empty?)
275
855
  return nil if palette.empty?
276
856
 
277
- palette[index % palette.length]
857
+ position = normalize_palette_position(index, palette.length)
858
+ return palette[0] if position.nil?
859
+
860
+ lower_index = position.floor
861
+ upper_index = (lower_index + 1) % palette.length
862
+ blend = position - lower_index
863
+
864
+ base_color = palette[lower_index]
865
+ return base_color unless blend.positive? && blend < 1.0
866
+
867
+ lower_rgb = parse_hex_color(base_color)
868
+ upper_rgb = parse_hex_color(palette[upper_index])
869
+ return base_color if lower_rgb.nil? || upper_rgb.nil?
870
+
871
+ interpolate_hex_color(lower_rgb, upper_rgb, blend)
278
872
  end
279
873
 
280
874
  def parse_hex_color(value)
@@ -286,6 +880,28 @@ module Vizcore
286
880
  [hex[0, 2], hex[2, 2], hex[4, 2]].map { |component| component.to_i(16) }
287
881
  end
288
882
 
883
+ def normalize_palette_position(value, palette_length)
884
+ return nil unless palette_length.positive?
885
+
886
+ numeric = Float(value)
887
+ return nil unless numeric.finite?
888
+
889
+ position = numeric % palette_length
890
+ return nil if position.nan?
891
+
892
+ position
893
+ rescue StandardError
894
+ nil
895
+ end
896
+
897
+ def interpolate_hex_color(left_rgb, right_rgb, blend)
898
+ blend = blend.to_f.clamp(0.0, 1.0)
899
+ rgb = left_rgb.zip(right_rgb).map do |left, right|
900
+ (left + (right - left) * blend).round.clamp(0, 255)
901
+ end
902
+ format("##{rgb.map { |value| format('%02x', value) }.join}")
903
+ end
904
+
289
905
  def default_layer
290
906
  { type: "geometry", name: "snapshot" }
291
907
  end
@@ -316,11 +932,12 @@ module Vizcore
316
932
 
317
933
  # Tiny RGBA canvas with alpha blending and a few primitive drawing helpers.
318
934
  class Canvas
319
- def initialize(width:, height:)
935
+ def initialize(width:, height:, transparent: false)
320
936
  @width = width
321
937
  @height = height
322
938
  @bytes = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
323
- @bytes << ([0, 0, 0, 255].pack("C4") * (width * height))
939
+ alpha = transparent ? 0 : 255
940
+ @bytes << ([0, 0, 0, alpha].pack("C4") * (width * height))
324
941
  end
325
942
 
326
943
  attr_reader :width, :height, :bytes
@@ -426,7 +1043,8 @@ module Vizcore
426
1043
  current = bytes.getbyte(offset + index)
427
1044
  bytes.setbyte(offset + index, interpolate(current, color[index], amount).round)
428
1045
  end
429
- bytes.setbyte(offset + 3, 255)
1046
+ alpha = bytes.getbyte(offset + 3)
1047
+ bytes.setbyte(offset + 3, [alpha + (255 - alpha) * amount, 255].min.round)
430
1048
  end
431
1049
 
432
1050
  def set_pixel(x, y, color, alpha)