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
@@ -2,30 +2,85 @@
2
2
 
3
3
  require_relative "mapping_transform_builder"
4
4
  require_relative "reaction_builder"
5
+ require_relative "../deep_copy"
6
+ require_relative "../layer_catalog"
7
+ require_relative "../shape"
8
+ require_relative "color_helpers"
9
+ require_relative "layout_helpers"
5
10
 
6
11
  module Vizcore
7
12
  module DSL
8
13
  # Builder for one render layer in a scene.
9
14
  class LayerBuilder
10
15
  NO_ARGUMENT = Object.new.freeze
16
+ SHAPE_SCHEMA_VERSION = 2
11
17
  MAPPING_SOURCE_KINDS = %i[
12
- amplitude frequency_band fft_spectrum onset kick snare hihat beat beat_confidence beat_pulse beat_count bpm
18
+ amplitude peak frequency_band frequency_band_peak fft_spectrum onset kick snare hihat beat beat_confidence beat_pulse beat_count bpm
19
+ beat_phase beat_2 beat_4 beat_8 beat_triplet triplet bar_phase bar_count phrase_count bpm_confidence
20
+ spectral_centroid spectral_rolloff spectral_flatness spectral_flux zero_crossing_rate global lfo adsr envelope
13
21
  ].freeze
22
+ PATH_DEFAULT_DETAIL = 32
23
+ PATH_MIN_DETAIL = 4
24
+ PATH_MAX_DETAIL = 128
25
+ PATH_DEFAULT_MAX_SEGMENTS = 4096
26
+ SHAPE_TARGET_ALIASES = {
27
+ "translate_x" => "transform.translate.x",
28
+ "translate_y" => "transform.translate.y",
29
+ "rotate" => "transform.rotate",
30
+ "rotation" => "transform.rotate",
31
+ "scale" => "transform.scale",
32
+ "scale_x" => "transform.scale.x",
33
+ "scale_y" => "transform.scale.y",
34
+ "origin_x" => "transform.origin.x",
35
+ "origin_y" => "transform.origin.y"
36
+ }.freeze
37
+ SHAPE_STYLE_KEYS = Vizcore::Shape::STYLE_KEYS
38
+ SHAPE_TRANSFORM_KEYS = %i[translate rotate rotation scale origin].freeze
39
+ STRICT_PARAM_ALLOWLIST = %i[
40
+ custom_shape_controls custom_shapes glsl_source group origin rotate scale translate
41
+ ].freeze
42
+
43
+ # Reference to an already declared shape, used by `map ... to: shape(:id).radius`.
44
+ class ShapeReference
45
+ def initialize(prefix)
46
+ @prefix = prefix
47
+ end
48
+
49
+ def method_missing(method_name, *args, &block)
50
+ return super unless args.empty? && block.nil?
51
+
52
+ target = SHAPE_TARGET_ALIASES.fetch(method_name.to_s, method_name.to_s)
53
+ :"#{@prefix}.#{target}"
54
+ end
55
+
56
+ def respond_to_missing?(_method_name, _include_private = false)
57
+ true
58
+ end
59
+ end
14
60
 
15
61
  # @param name [Symbol, String] layer identifier
16
62
  # @param styles [Hash] reusable layer parameter styles
17
63
  # @param defaults [Hash] default params applied before layer-specific values
18
- def initialize(name:, styles: {}, defaults: {})
64
+ # @param strict [Boolean] true when unknown layer params should fail
65
+ # @param mapping_presets [Hash] reusable mapping presets
66
+ def initialize(name:, styles: {}, defaults: {}, mapping_presets: {}, strict: false)
19
67
  @name = name.to_sym
20
68
  @styles = styles
69
+ @mapping_presets = mapping_presets
70
+ @strict = !!strict
21
71
  @type = nil
22
72
  @shader = nil
23
73
  @glsl = nil
24
74
  @params = deep_dup(defaults)
25
75
  @param_schema = {}
26
76
  @mappings = []
77
+ @shape_index_by_id = {}
78
+ @shape_group_stack = [{}]
27
79
  end
28
80
 
81
+ include ColorHelpers
82
+ include LayoutHelpers
83
+
29
84
  # Evaluate a layer block.
30
85
  #
31
86
  # @yield Layer DSL methods
@@ -72,8 +127,8 @@ module Vizcore
72
127
  # @param options [Hash] shape params such as `count`, `radius`, `x`, and `y`
73
128
  # @yield optional block evaluated in the shape context
74
129
  # @return [Hash]
75
- def circle(**options, &block)
76
- build_shape(:circle, options, &block)
130
+ def circle(id = nil, **options, &block)
131
+ build_shape(:circle, shape_options(id, options), &block)
77
132
  end
78
133
 
79
134
  # Declare a 2D line primitive for a shape layer.
@@ -81,8 +136,133 @@ module Vizcore
81
136
  # @param options [Hash] shape params such as `x1`, `y1`, `x2`, and `y2`
82
137
  # @yield optional block evaluated in the shape context
83
138
  # @return [Hash]
84
- def line(**options, &block)
85
- build_shape(:line, options, &block)
139
+ def line(id = nil, **options, &block)
140
+ build_shape(:line, shape_options(id, options), &block)
141
+ end
142
+
143
+ # Declare a 2D rectangle primitive for a shape layer.
144
+ #
145
+ # @param id [Symbol, String, nil] optional shape identifier
146
+ # @param options [Hash] shape params such as `x`, `y`, `width`, `height`, and `radius`
147
+ # @yield optional block evaluated in the shape context
148
+ # @return [Hash]
149
+ def rect(id = nil, **options, &block)
150
+ build_shape(:rect, shape_options(id, options), schema_version: true, &block)
151
+ end
152
+
153
+ # Declare a closed polygon primitive for a shape layer.
154
+ #
155
+ # @param id [Symbol, String, nil] optional shape identifier
156
+ # @param options [Hash] shape params including `points`
157
+ # @yield optional block evaluated in the shape context
158
+ # @return [Hash]
159
+ def polygon(id = nil, **options, &block)
160
+ build_shape(:polygon, shape_options(id, options), schema_version: true, &block)
161
+ end
162
+
163
+ # Declare an open polyline primitive for a shape layer.
164
+ #
165
+ # @param id [Symbol, String, nil] optional shape identifier
166
+ # @param options [Hash] shape params including `points`
167
+ # @yield optional block evaluated in the shape context
168
+ # @return [Hash]
169
+ def polyline(id = nil, **options, &block)
170
+ build_shape(:polyline, shape_options(id, options).merge(closed: false), schema_version: true, &block)
171
+ end
172
+
173
+ # Declare a path primitive using SVG-like path commands.
174
+ #
175
+ # @param id [Symbol, String, nil] optional shape identifier
176
+ # @param options [Hash] path params such as `detail`
177
+ # @yield block containing path commands and shape styling
178
+ # @return [Hash]
179
+ def path(id = nil, **options, &block)
180
+ shape = shape_options(id, options)
181
+ shape[:commands] ||= []
182
+ build_shape(:path, shape, schema_version: true, &block)
183
+ end
184
+
185
+ # Declare a quadratic or cubic bezier curve. The serialized primitive is a path.
186
+ #
187
+ # @param id [Symbol, String, nil] optional shape identifier
188
+ # @param from [Array<Numeric>] start point
189
+ # @param to [Array<Numeric>] end point
190
+ # @param control [Array<Numeric>, nil] quadratic control point
191
+ # @param c1 [Array<Numeric>, nil] first cubic control point
192
+ # @param c2 [Array<Numeric>, nil] second cubic control point
193
+ # @param options [Hash] additional path params
194
+ # @yield optional block evaluated in the shape context
195
+ # @return [Hash]
196
+ def bezier(id = nil, from:, to:, control: nil, c1: nil, c2: nil, **options, &block)
197
+ commands = [["M", *point_values(from)]]
198
+ if control
199
+ commands << ["Q", *point_values(control), *point_values(to)]
200
+ elsif c1 && c2
201
+ commands << ["C", *point_values(c1), *point_values(c2), *point_values(to)]
202
+ else
203
+ raise ArgumentError, "bezier requires either :control or both :c1 and :c2"
204
+ end
205
+
206
+ build_shape(:path, shape_options(id, options).merge(commands: commands), schema_version: true, &block)
207
+ end
208
+
209
+ # Declare a star polygon primitive for a shape layer.
210
+ #
211
+ # @param id [Symbol, String, nil] optional shape identifier
212
+ # @param options [Hash] shape params such as `points`, `radius`, and `inner_radius`
213
+ # @yield optional block evaluated in the shape context
214
+ # @return [Hash]
215
+ def star(id = nil, **options, &block)
216
+ build_shape(:star, shape_options(id, options), schema_version: true, &block)
217
+ end
218
+
219
+ # Expand a registered Ruby custom shape into normal shape primitives.
220
+ #
221
+ # @param renderer [Symbol, String, Class, Module, #call] registered shape name or renderer
222
+ # @param options [Hash] custom shape params
223
+ # @yield optional block applied to each generated primitive
224
+ # @return [Array<Hash>]
225
+ def custom_shape(renderer, **options, &block)
226
+ mark_shape_schema_version!
227
+ shape_id = options.delete(:id)
228
+ dynamic = options.delete(:dynamic)
229
+ static = options.delete(:static)
230
+ raise ArgumentError, "custom_shape cannot be both static and dynamic" if dynamic && static
231
+
232
+ dynamic = true if static == false
233
+ return append_dynamic_custom_shape(renderer, options, shape_id: shape_id, &block) if dynamic
234
+
235
+ primitives = expand_custom_shape(renderer, options, shape_id: shape_id, cache: !!static)
236
+ raise ArgumentError, "custom_shape produced no primitives" if primitives.empty?
237
+ raise ArgumentError, "custom_shape id can only be assigned when one primitive is produced" if shape_id && primitives.length > 1
238
+
239
+ @type ||= :shape
240
+ @params[:shapes] ||= []
241
+ primitives.map do |primitive|
242
+ primitive[:id] ||= shape_id.to_sym if shape_id
243
+ append_expanded_shape(primitive, &block)
244
+ end
245
+ end
246
+
247
+ # Apply shared style and transform to shape primitives declared in the block.
248
+ #
249
+ # Group attributes are flattened into child primitives so the frontend only
250
+ # needs to render regular shape primitives.
251
+ #
252
+ # @param id [Symbol, String, nil] optional group identifier, currently documentation-only
253
+ # @param attrs [Hash] initial group style/transform attrs
254
+ # @yield shape declarations
255
+ # @return [Array<Hash>]
256
+ def group(_id = nil, **attrs, &block)
257
+ raise ArgumentError, "group requires a block" unless block
258
+
259
+ mark_shape_schema_version!
260
+ @type ||= :shape
261
+ @shape_group_stack << merge_shape_group(current_shape_group, normalize_shape_group(attrs))
262
+ instance_eval(&block)
263
+ @params[:shapes] || []
264
+ ensure
265
+ @shape_group_stack.pop if @shape_group_stack.length > 1
86
266
  end
87
267
 
88
268
  # Group shape primitives in a block for readability.
@@ -146,6 +326,22 @@ module Vizcore
146
326
  # @param value [String] text fill color
147
327
  # @return [String]
148
328
  def fill(value)
329
+ if @current_shape
330
+ @current_shape[:fill] = value.to_s
331
+ mark_shape_schema_version!
332
+ return @current_shape
333
+ end
334
+
335
+ if @current_custom_shape
336
+ current_custom_shape_style[:fill] = value.to_s
337
+ return @current_custom_shape
338
+ end
339
+
340
+ if in_shape_group?
341
+ current_shape_group[:fill] = value.to_s
342
+ return current_shape_group
343
+ end
344
+
149
345
  @params[:color] = value.to_s
150
346
  end
151
347
 
@@ -160,6 +356,20 @@ module Vizcore
160
356
  return @current_shape
161
357
  end
162
358
 
359
+ if @current_custom_shape
360
+ current_custom_shape_style[:stroke] = normalize_non_negative_param_number(value, :stroke) unless value.equal?(NO_ARGUMENT)
361
+ current_custom_shape_style[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
362
+ current_custom_shape_style[:stroke_color] = color.to_s unless color.nil?
363
+ return @current_custom_shape
364
+ end
365
+
366
+ if in_shape_group?
367
+ current_shape_group[:stroke] = normalize_non_negative_param_number(value, :stroke) unless value.equal?(NO_ARGUMENT)
368
+ current_shape_group[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
369
+ current_shape_group[:stroke_color] = color.to_s unless color.nil?
370
+ return current_shape_group
371
+ end
372
+
163
373
  @params[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
164
374
  @params[:stroke_color] = color.to_s unless color.nil?
165
375
  @params
@@ -177,9 +387,198 @@ module Vizcore
177
387
  # @param value [Symbol, String] layer compositing mode
178
388
  # @return [Symbol]
179
389
  def blend(value)
390
+ if @current_shape
391
+ @current_shape[:blend] = value.to_sym
392
+ mark_shape_schema_version!
393
+ return @current_shape
394
+ end
395
+
396
+ if @current_custom_shape
397
+ current_custom_shape_style[:blend] = value.to_sym
398
+ return @current_custom_shape
399
+ end
400
+
401
+ if in_shape_group?
402
+ current_shape_group[:blend] = value.to_sym
403
+ return current_shape_group
404
+ end
405
+
180
406
  @params[:blend] = value.to_sym
181
407
  end
182
408
 
409
+ # Set layer or shape opacity.
410
+ #
411
+ # @param value [Numeric]
412
+ # @return [Float, Hash]
413
+ def opacity(value)
414
+ if @current_shape
415
+ @current_shape[:opacity] = normalize_param_number(value, :opacity)
416
+ mark_shape_schema_version!
417
+ return @current_shape
418
+ end
419
+
420
+ if @current_custom_shape
421
+ current_custom_shape_style[:opacity] = normalize_param_number(value, :opacity)
422
+ return @current_custom_shape
423
+ end
424
+
425
+ if in_shape_group?
426
+ current_shape_group[:opacity] = current_shape_group.key?(:opacity) ? normalize_param_number(current_shape_group[:opacity], :opacity) * normalize_param_number(value, :opacity) : normalize_param_number(value, :opacity)
427
+ return current_shape_group
428
+ end
429
+
430
+ @params[:opacity] = normalize_param_number(value, :opacity)
431
+ end
432
+
433
+ # Set a shape/layer translation transform.
434
+ #
435
+ # @param args [Array<Numeric>]
436
+ # @param x [Numeric, nil]
437
+ # @param y [Numeric, nil]
438
+ # @return [Hash]
439
+ def translate(*args, x: nil, y: nil)
440
+ values = normalize_xy_args(args, x: x, y: y, name: :translate)
441
+ if @current_shape
442
+ current_shape_transform[:translate] = values
443
+ return @current_shape
444
+ end
445
+
446
+ if @current_custom_shape
447
+ current_custom_shape_transform[:translate] = add_shape_xy(current_custom_shape_transform[:translate], values)
448
+ return @current_custom_shape
449
+ end
450
+
451
+ if in_shape_group?
452
+ current_shape_group_transform[:translate] = add_shape_xy(current_shape_group_transform[:translate], values)
453
+ return current_shape_group
454
+ end
455
+
456
+ @params[:translate] = values
457
+ end
458
+
459
+ # Set a shape/layer rotation transform in degrees.
460
+ #
461
+ # @param value [Numeric]
462
+ # @return [Float, Hash]
463
+ def rotate(value)
464
+ rotation = normalize_param_number(value, :rotate)
465
+ if @current_shape
466
+ current_shape_transform[:rotate] = rotation
467
+ return @current_shape
468
+ end
469
+
470
+ if @current_custom_shape
471
+ current_custom_shape_transform[:rotate] = normalize_param_number(current_custom_shape_transform[:rotate] || 0, :rotate) + rotation
472
+ return @current_custom_shape
473
+ end
474
+
475
+ if in_shape_group?
476
+ current_shape_group_transform[:rotate] = normalize_param_number(current_shape_group_transform[:rotate] || 0, :rotate) + rotation
477
+ return current_shape_group
478
+ end
479
+
480
+ @params[:rotate] = rotation
481
+ end
482
+
483
+ # Set a shape/layer scale transform.
484
+ #
485
+ # @param value [Numeric]
486
+ # @param x [Numeric, nil]
487
+ # @param y [Numeric, nil]
488
+ # @return [Float, Hash]
489
+ def scale(value = NO_ARGUMENT, x: nil, y: nil)
490
+ scale_value = normalize_scale_args(value, x: x, y: y)
491
+ if @current_shape
492
+ current_shape_transform[:scale] = scale_value
493
+ return @current_shape
494
+ end
495
+
496
+ if @current_custom_shape
497
+ current_custom_shape_transform[:scale] = multiply_shape_scale(current_custom_shape_transform[:scale], scale_value)
498
+ return @current_custom_shape
499
+ end
500
+
501
+ if in_shape_group?
502
+ current_shape_group_transform[:scale] = multiply_shape_scale(current_shape_group_transform[:scale], scale_value)
503
+ return current_shape_group
504
+ end
505
+
506
+ @params[:scale] = scale_value
507
+ end
508
+
509
+ # Set a shape/layer transform origin.
510
+ #
511
+ # @param args [Array<Numeric>]
512
+ # @param x [Numeric, nil]
513
+ # @param y [Numeric, nil]
514
+ # @return [Hash]
515
+ def origin(*args, x: nil, y: nil)
516
+ values = normalize_xy_args(args, x: x, y: y, name: :origin)
517
+ if @current_shape
518
+ current_shape_transform[:origin] = values
519
+ return @current_shape
520
+ end
521
+
522
+ if @current_custom_shape
523
+ current_custom_shape_transform[:origin] = values
524
+ return @current_custom_shape
525
+ end
526
+
527
+ if in_shape_group?
528
+ current_shape_group_transform[:origin] = values
529
+ return current_shape_group
530
+ end
531
+
532
+ @params[:origin] = values
533
+ end
534
+
535
+ # Return a reference object for mapping to a named shape.
536
+ #
537
+ # @param id [Symbol, String]
538
+ # @return [ShapeReference]
539
+ def shape(id)
540
+ key = id.to_sym
541
+ index = @shape_index_by_id.fetch(key) do
542
+ suggestion_message = shape_id_suggestions(key)
543
+ message = "unknown shape id: #{key.inspect}"
544
+ message = "#{message}. Did you mean: #{suggestion_message}" unless suggestion_message.empty?
545
+ raise ArgumentError, message
546
+ end
547
+ ShapeReference.new("shapes.#{index}")
548
+ end
549
+
550
+ def move_to(x, y)
551
+ append_path_command("M", x, y)
552
+ end
553
+
554
+ def line_to(x, y)
555
+ append_path_command("L", x, y)
556
+ end
557
+
558
+ def quad_to(cx, cy, x, y)
559
+ append_path_command("Q", cx, cy, x, y)
560
+ end
561
+
562
+ def cubic_to(c1x, c1y, c2x, c2y, x, y)
563
+ append_path_command("C", c1x, c1y, c2x, c2y, x, y)
564
+ end
565
+
566
+ def horizontal_to(x)
567
+ append_path_command("H", x)
568
+ end
569
+
570
+ def vertical_to(y)
571
+ append_path_command("V", y)
572
+ end
573
+
574
+ def arc_to(rx, ry, rotation, large_arc, sweep, x, y)
575
+ append_path_command("A", rx, ry, rotation, large_arc, sweep, x, y)
576
+ end
577
+
578
+ def close
579
+ append_path_command("Z")
580
+ end
581
+
183
582
  # Store an ordered color palette for this layer.
184
583
  #
185
584
  # @param colors [Array<String, Array<String>>] color values such as "#00ffff"
@@ -189,6 +588,17 @@ module Vizcore
189
588
  @params[:palette] = normalize_palette(colors)
190
589
  end
191
590
 
591
+ # Append one post effect to this layer's post effect chain.
592
+ #
593
+ # @param name [Symbol, String] effect key
594
+ # @return [Symbol]
595
+ def post(name)
596
+ raise ArgumentError, "post expects a symbol or string" unless name
597
+
598
+ @params[:post_effects] ||= []
599
+ @params[:post_effects] << name.to_sym
600
+ end
601
+
192
602
  # Apply a named style by merging its params into this layer.
193
603
  #
194
604
  # @param name [Symbol, String] style identifier
@@ -232,6 +642,7 @@ module Vizcore
232
642
  # @raise [ArgumentError] when the mapping is empty or invalid
233
643
  # @return [void]
234
644
  def map(definition = nil, **options, &block)
645
+ definition, options = normalize_custom_shape_mapping(definition, options) if @custom_shape_target_prefix
235
646
  definition, options = normalize_shape_mapping(definition, options) if @shape_target_prefix
236
647
 
237
648
  if options.key?(:to)
@@ -257,6 +668,17 @@ module Vizcore
257
668
  end
258
669
  end
259
670
 
671
+ # Apply a named mapping preset to this layer.
672
+ #
673
+ # @param name [Symbol, String] mapping preset identifier
674
+ # @raise [ArgumentError] when the preset is unknown
675
+ # @return [void]
676
+ def use_mapping(name)
677
+ preset_name = name.to_sym
678
+ preset = @mapping_presets.fetch(preset_name) { raise ArgumentError, "unknown mapping preset: #{preset_name}" }
679
+ preset.each { |mapping| @mappings << deep_dup(mapping) }
680
+ end
681
+
260
682
  # High-level mapping DSL for describing audio reactions inside a layer.
261
683
  #
262
684
  # @param source_value [Hash, Symbol, String] analysis source descriptor
@@ -284,42 +706,82 @@ module Vizcore
284
706
  mapping_source(:amplitude)
285
707
  end
286
708
 
709
+ # @return [Hash] source descriptor for absolute sample peak level
710
+ def peak
711
+ mapping_source(:peak)
712
+ end
713
+
287
714
  # @param name [Symbol, String] band key (`sub`, `low`, `mid`, `high`)
288
715
  # @return [Hash] source descriptor for a frequency band
289
716
  def frequency_band(name)
290
717
  mapping_source(:frequency_band, band: name.to_sym)
291
718
  end
292
719
 
720
+ # @return [Hash] source descriptor for a held frequency-band peak
721
+ def frequency_band_peak(name)
722
+ mapping_source(:frequency_band_peak, band: name.to_sym)
723
+ end
724
+
293
725
  # @return [Hash] source descriptor for the sub-bass frequency band
294
726
  def sub
295
727
  frequency_band(:sub)
296
728
  end
297
729
 
730
+ # @return [Hash] source descriptor for the held sub-bass peak
731
+ def sub_peak
732
+ frequency_band_peak(:sub)
733
+ end
734
+
298
735
  # @return [Hash] source descriptor for the low/bass frequency band
299
736
  def low
300
737
  frequency_band(:low)
301
738
  end
302
739
 
740
+ # @return [Hash] source descriptor for the held low/bass peak
741
+ def low_peak
742
+ frequency_band_peak(:low)
743
+ end
744
+
303
745
  # @return [Hash] source descriptor for the low/bass frequency band
304
746
  def bass
305
747
  frequency_band(:low)
306
748
  end
307
749
 
750
+ # @return [Hash] source descriptor for the held low/bass peak
751
+ def bass_peak
752
+ frequency_band_peak(:low)
753
+ end
754
+
308
755
  # @return [Hash] source descriptor for the mid frequency band
309
756
  def mid
310
757
  frequency_band(:mid)
311
758
  end
312
759
 
760
+ # @return [Hash] source descriptor for the held mid peak
761
+ def mid_peak
762
+ frequency_band_peak(:mid)
763
+ end
764
+
313
765
  # @return [Hash] source descriptor for the high frequency band
314
766
  def high
315
767
  frequency_band(:high)
316
768
  end
317
769
 
770
+ # @return [Hash] source descriptor for the held high peak
771
+ def high_peak
772
+ frequency_band_peak(:high)
773
+ end
774
+
318
775
  # @return [Hash] source descriptor for the high/treble frequency band
319
776
  def treble
320
777
  frequency_band(:high)
321
778
  end
322
779
 
780
+ # @return [Hash] source descriptor for the held high/treble peak
781
+ def treble_peak
782
+ frequency_band_peak(:high)
783
+ end
784
+
323
785
  # @return [Hash] source descriptor for FFT spectrum array
324
786
  def fft_spectrum
325
787
  mapping_source(:fft_spectrum)
@@ -378,13 +840,138 @@ module Vizcore
378
840
  mapping_source(:beat_count)
379
841
  end
380
842
 
843
+ # @return [Hash] source descriptor for 0.0..1.0 phase within the current beat
844
+ def beat_phase
845
+ mapping_source(:beat_phase)
846
+ end
847
+
848
+ # @return [Hash] source descriptor for half-beat subdivision pulses
849
+ def beat_2
850
+ mapping_source(:beat_2)
851
+ end
852
+
853
+ # @return [Hash] source descriptor for quarter-beat subdivision pulses
854
+ def beat_4
855
+ mapping_source(:beat_4)
856
+ end
857
+
858
+ # @return [Hash] source descriptor for eighth-beat subdivision pulses
859
+ def beat_8
860
+ mapping_source(:beat_8)
861
+ end
862
+
863
+ # @return [Hash] source descriptor for triplet subdivision pulses
864
+ def beat_triplet
865
+ mapping_source(:beat_triplet)
866
+ end
867
+
868
+ # @return [Hash] source descriptor for triplet subdivision pulses
869
+ def triplet
870
+ mapping_source(:beat_triplet)
871
+ end
872
+
873
+ # @return [Hash] source descriptor for 0.0..1.0 phase within the current 4-beat bar
874
+ def bar_phase
875
+ mapping_source(:bar_phase)
876
+ end
877
+
878
+ # @return [Hash] source descriptor for completed 4-beat bars
879
+ def bar_count(value = NO_ARGUMENT)
880
+ return @params[:bar_count] = Integer(value) unless value.equal?(NO_ARGUMENT)
881
+
882
+ mapping_source(:bar_count)
883
+ end
884
+
885
+ # @return [Hash] source descriptor for completed 8-bar phrases
886
+ def phrase_count
887
+ mapping_source(:phrase_count)
888
+ end
889
+
381
890
  # @return [Hash] source descriptor for estimated BPM
382
891
  def bpm
383
892
  mapping_source(:bpm)
384
893
  end
385
894
 
895
+ # @return [Hash] source descriptor for tempo estimator confidence
896
+ def bpm_confidence
897
+ mapping_source(:bpm_confidence)
898
+ end
899
+
900
+ # @return [Hash] source descriptor for spectral centroid in Hz
901
+ def spectral_centroid
902
+ mapping_source(:spectral_centroid)
903
+ end
904
+
905
+ # @return [Hash] source descriptor for spectral rolloff in Hz
906
+ def spectral_rolloff
907
+ mapping_source(:spectral_rolloff)
908
+ end
909
+
910
+ # @return [Hash] source descriptor for spectral flatness
911
+ def spectral_flatness
912
+ mapping_source(:spectral_flatness)
913
+ end
914
+
915
+ # @return [Hash] source descriptor for positive spectrum delta
916
+ def spectral_flux
917
+ mapping_source(:spectral_flux)
918
+ end
919
+
920
+ # @return [Hash] source descriptor for time-domain zero crossing rate
921
+ def zero_crossing_rate
922
+ mapping_source(:zero_crossing_rate)
923
+ end
924
+
925
+ # @param name [Symbol, String] runtime global value name
926
+ # @return [Hash] source descriptor for mutable runtime globals
927
+ def global(name)
928
+ mapping_source(:global, name: name.to_sym)
929
+ end
930
+
931
+ # @param wave [Symbol, String] one of `sine`, `triangle`, `saw`, `square`
932
+ # @param rate [Numeric] cycles per second
933
+ # @param phase [Numeric] phase offset in cycles
934
+ # @return [Hash] source descriptor for a time-based low-frequency oscillator
935
+ def lfo(wave = :sine, rate: 1.0, phase: 0.0)
936
+ mapping_source(:lfo, wave: wave.to_sym, rate: Float(rate), phase: Float(phase))
937
+ rescue ArgumentError, TypeError
938
+ raise ArgumentError, "lfo rate and phase must be numeric"
939
+ end
940
+
941
+ # @param source [Symbol, Hash] source to trigger the envelope
942
+ # @param attack [Numeric] seconds from 0.0 to peak
943
+ # @param decay [Numeric] seconds from peak to sustain
944
+ # @param sustain [Numeric] sustain gain once decay is complete (0.0..1.0)
945
+ # @param release [Numeric] seconds from sustain to 0.0
946
+ # @param threshold [Numeric] value threshold that starts attack
947
+ # @param peak [Numeric] peak gain multiplier
948
+ # @return [Hash] source descriptor for an ADSR envelope
949
+ def adsr(source = :kick, attack: 0.02, decay: 0.08, sustain: 0.7, release: 0.16, threshold: 0.0, peak: 1.0)
950
+ source_value = source.nil? ? { kind: :kick } : normalize_source(source)
951
+ mapping_source(
952
+ :adsr,
953
+ source: source_value,
954
+ attack: normalize_non_negative_float(attack, :attack),
955
+ decay: normalize_non_negative_float(decay, :decay),
956
+ sustain: clamp(normalize_float(sustain, :sustain), 0.0, 1.0),
957
+ release: normalize_non_negative_float(release, :release),
958
+ threshold: normalize_float(threshold, :threshold),
959
+ peak: normalize_float(peak, :peak)
960
+ )
961
+ end
962
+
963
+ # Alias for ADSR envelope mapping source.
964
+ #
965
+ # @param source [Symbol, Hash] source to trigger the envelope
966
+ # @param options [Numeric] envelope timing and shaping values
967
+ # @return [Hash] source descriptor for a general envelope
968
+ def envelope(source = :kick, **options)
969
+ adsr(source, **options)
970
+ end
971
+
386
972
  # @return [Hash] serialized layer payload
387
973
  def to_h
974
+ validate_strict_params! if @strict
388
975
  layer = {
389
976
  name: @name,
390
977
  type: resolved_type,
@@ -405,6 +992,16 @@ module Vizcore
405
992
  return args.first
406
993
  end
407
994
 
995
+ if @current_custom_shape && block.nil? && args.length == 1
996
+ @current_custom_shape[:params][method_name.to_sym] = args.first
997
+ return args.first
998
+ end
999
+
1000
+ if in_shape_group? && block.nil? && args.length == 1
1001
+ current_shape_group[method_name.to_sym] = args.first
1002
+ return args.first
1003
+ end
1004
+
408
1005
  if block.nil? && args.length == 1
409
1006
  @params[method_name.to_sym] = args.first
410
1007
  return args.first
@@ -414,25 +1011,77 @@ module Vizcore
414
1011
  end
415
1012
 
416
1013
  def respond_to_missing?(method_name, include_private = false)
417
- @params.key?(method_name.to_sym) || super
1014
+ !!@current_custom_shape || @params.key?(method_name.to_sym) || super
418
1015
  end
419
1016
 
420
1017
  private
421
1018
 
422
- def build_shape(kind, options, &block)
1019
+ def build_shape(kind, options, schema_version: false, &block)
423
1020
  @type ||= :shape
1021
+ mark_shape_schema_version! if schema_version
424
1022
  shape = normalize_shape(kind, options)
425
1023
  @params[:shapes] ||= []
426
1024
  shape_index = @params[:shapes].length
1025
+ register_shape_id!(shape, shape_index)
427
1026
  @params[:shapes] << shape
428
1027
 
429
1028
  with_shape_context(shape, shape_index) do
430
1029
  instance_eval(&block) if block
431
1030
  end
1031
+ apply_current_shape_group!(shape)
1032
+ validate_shape!(shape)
432
1033
 
433
1034
  shape
434
1035
  end
435
1036
 
1037
+ def append_expanded_shape(shape, &block)
1038
+ shape_index = @params[:shapes].length
1039
+ register_shape_id!(shape, shape_index)
1040
+ @params[:shapes] << shape
1041
+
1042
+ with_shape_context(shape, shape_index) do
1043
+ instance_eval(&block) if block
1044
+ end
1045
+ apply_current_shape_group!(shape)
1046
+ validate_shape!(shape)
1047
+
1048
+ shape
1049
+ end
1050
+
1051
+ def append_dynamic_custom_shape(renderer, options, shape_id:, &block)
1052
+ definition = custom_shape_definition(renderer)
1053
+ @type ||= :shape
1054
+ @params[:custom_shapes] ||= []
1055
+ descriptor = {
1056
+ name: definition.name || renderer,
1057
+ renderer: definition.renderer,
1058
+ params: deep_dup(options),
1059
+ style: {},
1060
+ transform: {},
1061
+ dynamic: true
1062
+ }
1063
+ param_schema = custom_shape_param_schema(definition.renderer)
1064
+ descriptor[:param_schema] = param_schema unless param_schema.empty?
1065
+ descriptor[:shape_id] = shape_id.to_sym if shape_id
1066
+ descriptor_index = @params[:custom_shapes].length
1067
+ @params[:custom_shapes] << descriptor
1068
+
1069
+ with_custom_shape_context(descriptor, descriptor_index) do
1070
+ instance_eval(&block) if block
1071
+ end
1072
+ apply_current_shape_group_to_custom_shape!(descriptor)
1073
+
1074
+ descriptor
1075
+ end
1076
+
1077
+ def shape_options(id, options)
1078
+ return options if id.nil?
1079
+
1080
+ raise ArgumentError, "shape id specified twice" if options.key?(:id)
1081
+
1082
+ options.merge(id: id.to_sym)
1083
+ end
1084
+
436
1085
  def normalize_shape(kind, options)
437
1086
  shape = { kind: kind.to_sym }
438
1087
  options.each do |key, value|
@@ -441,6 +1090,307 @@ module Vizcore
441
1090
  shape
442
1091
  end
443
1092
 
1093
+ def normalize_shape_group(attrs)
1094
+ attrs.each_with_object({}) do |(key, value), group|
1095
+ symbol_key = key.to_sym
1096
+ if SHAPE_TRANSFORM_KEYS.include?(symbol_key)
1097
+ transform_key = symbol_key == :rotation ? :rotate : symbol_key
1098
+ group[:transform] ||= {}
1099
+ group[:transform][transform_key] = value
1100
+ else
1101
+ group[symbol_key] = value
1102
+ end
1103
+ end
1104
+ end
1105
+
1106
+ def merge_shape_group(parent, child)
1107
+ output = deep_dup(parent)
1108
+ child.each do |key, value|
1109
+ if key == :transform
1110
+ output[:transform] = compose_shape_transform(output[:transform], value)
1111
+ elsif key == :opacity && output.key?(:opacity)
1112
+ output[:opacity] = normalize_param_number(output[:opacity], :opacity) * normalize_param_number(value, :opacity)
1113
+ else
1114
+ output[key] = deep_dup(value)
1115
+ end
1116
+ end
1117
+ output
1118
+ end
1119
+
1120
+ def apply_current_shape_group!(shape)
1121
+ group = current_shape_group
1122
+ return shape if group.empty?
1123
+
1124
+ SHAPE_STYLE_KEYS.each do |key|
1125
+ next unless group.key?(key)
1126
+
1127
+ if key == :opacity && shape.key?(:opacity)
1128
+ shape[:opacity] = normalize_param_number(group[:opacity], :opacity) * normalize_param_number(shape[:opacity], :opacity)
1129
+ else
1130
+ shape[key] = deep_dup(group[key]) unless shape.key?(key)
1131
+ end
1132
+ end
1133
+ shape[:transform] = compose_shape_transform(group[:transform], shape[:transform]) if group[:transform]
1134
+ shape
1135
+ end
1136
+
1137
+ def apply_current_shape_group_to_custom_shape!(descriptor)
1138
+ group = current_shape_group
1139
+ return descriptor if group.empty?
1140
+
1141
+ style = descriptor[:style] ||= {}
1142
+ SHAPE_STYLE_KEYS.each do |key|
1143
+ next unless group.key?(key)
1144
+
1145
+ if key == :opacity && style.key?(:opacity)
1146
+ style[:opacity] = normalize_param_number(group[:opacity], :opacity) * normalize_param_number(style[:opacity], :opacity)
1147
+ else
1148
+ style[key] = deep_dup(group[key]) unless style.key?(key)
1149
+ end
1150
+ end
1151
+ descriptor[:transform] = compose_shape_transform(group[:transform], descriptor[:transform]) if group[:transform]
1152
+ descriptor
1153
+ end
1154
+
1155
+ def compose_shape_transform(parent, child)
1156
+ return deep_dup(child || {}) unless parent
1157
+
1158
+ child ||= {}
1159
+ output = deep_dup(parent)
1160
+ output[:translate] = add_shape_xy(parent[:translate], child[:translate]) if child.key?(:translate)
1161
+ output[:origin] = child[:origin] if child.key?(:origin)
1162
+ output[:rotate] = normalize_param_number(parent[:rotate] || 0, :rotate) + normalize_param_number(child[:rotate] || 0, :rotate) if child.key?(:rotate)
1163
+ output[:scale] = multiply_shape_scale(parent[:scale], child[:scale]) if child.key?(:scale)
1164
+ output
1165
+ end
1166
+
1167
+ def add_shape_xy(parent, child)
1168
+ parent ||= {}
1169
+ child ||= {}
1170
+ {
1171
+ x: normalize_param_number(parent[:x] || parent["x"] || 0, :"translate.x") + normalize_param_number(child[:x] || child["x"] || 0, :"translate.x"),
1172
+ y: normalize_param_number(parent[:y] || parent["y"] || 0, :"translate.y") + normalize_param_number(child[:y] || child["y"] || 0, :"translate.y")
1173
+ }
1174
+ end
1175
+
1176
+ def multiply_shape_scale(parent, child)
1177
+ parent = shape_scale_pair(parent)
1178
+ child = shape_scale_pair(child)
1179
+ { x: parent[:x] * child[:x], y: parent[:y] * child[:y] }
1180
+ end
1181
+
1182
+ def shape_scale_pair(value)
1183
+ return { x: normalize_param_number(value[:x] || value["x"] || 1, :"scale.x"), y: normalize_param_number(value[:y] || value["y"] || 1, :"scale.y") } if value.is_a?(Hash)
1184
+
1185
+ scale = normalize_param_number(value || 1, :scale)
1186
+ { x: scale, y: scale }
1187
+ end
1188
+
1189
+ def current_shape_group
1190
+ @shape_group_stack.last
1191
+ end
1192
+
1193
+ def current_shape_group_transform
1194
+ current_shape_group[:transform] ||= {}
1195
+ end
1196
+
1197
+ def current_custom_shape_style
1198
+ @current_custom_shape[:style] ||= {}
1199
+ end
1200
+
1201
+ def current_custom_shape_transform
1202
+ @current_custom_shape[:transform] ||= {}
1203
+ end
1204
+
1205
+ def in_shape_group?
1206
+ @shape_group_stack.length > 1
1207
+ end
1208
+
1209
+ def validate_shape!(shape)
1210
+ validate_non_negative_shape_numbers!(shape)
1211
+ case shape.fetch(:kind)
1212
+ when :polygon
1213
+ validate_shape_points!(shape, minimum: 3)
1214
+ when :polyline
1215
+ validate_shape_points!(shape, minimum: 2)
1216
+ when :path
1217
+ validate_path_shape!(shape)
1218
+ end
1219
+ end
1220
+
1221
+ def validate_path_shape!(shape)
1222
+ commands = Array(shape[:commands])
1223
+ raise ArgumentError, "Invalid path#{shape_label(shape)}: commands must not be empty" if commands.empty?
1224
+
1225
+ detail = normalized_path_integer(shape, :detail, PATH_DEFAULT_DETAIL).clamp(PATH_MIN_DETAIL, PATH_MAX_DETAIL)
1226
+ max_segments = normalized_path_integer(shape, :max_segments, PATH_DEFAULT_MAX_SEGMENTS)
1227
+ validate_path_tolerance!(shape)
1228
+
1229
+ segment_count = estimated_path_segments(commands, detail)
1230
+ return if segment_count <= max_segments
1231
+
1232
+ raise ArgumentError,
1233
+ "Invalid path#{shape_label(shape)}: max_segments exceeded (#{segment_count} > #{max_segments})"
1234
+ end
1235
+
1236
+ def normalized_path_integer(shape, key, default)
1237
+ value = shape.key?(key) ? shape[key] : default
1238
+ numeric = Integer(value)
1239
+ raise ArgumentError if numeric <= 0
1240
+
1241
+ numeric
1242
+ rescue ArgumentError, TypeError
1243
+ raise ArgumentError, "Invalid path#{shape_label(shape)}: #{key} must be a positive integer"
1244
+ end
1245
+
1246
+ def validate_path_tolerance!(shape)
1247
+ return unless shape.key?(:tolerance)
1248
+
1249
+ value = normalize_param_number(shape[:tolerance], :tolerance)
1250
+ return unless value.negative?
1251
+
1252
+ raise ArgumentError, "Invalid path#{shape_label(shape)}: tolerance must be non-negative"
1253
+ end
1254
+
1255
+ def estimated_path_segments(commands, detail)
1256
+ current = false
1257
+ subpath_start = false
1258
+ commands.sum do |entry|
1259
+ command, *values = Array(entry)
1260
+ case command.to_s.upcase
1261
+ when "M"
1262
+ current = values.length >= 2
1263
+ subpath_start = current
1264
+ 0
1265
+ when "L"
1266
+ current && values.length >= 2 ? 1 : 0
1267
+ when "H", "V"
1268
+ current && values.length >= 1 ? 1 : 0
1269
+ when "Q"
1270
+ current && values.length >= 4 ? detail : 0
1271
+ when "C"
1272
+ current && values.length >= 6 ? detail : 0
1273
+ when "A"
1274
+ current && values.length >= 7 ? detail : 0
1275
+ when "Z"
1276
+ current && subpath_start ? 1 : 0
1277
+ else
1278
+ 0
1279
+ end
1280
+ end
1281
+ end
1282
+
1283
+ def validate_non_negative_shape_numbers!(shape)
1284
+ %i[radius width height stroke_width inner_radius].each do |key|
1285
+ next unless shape.key?(key)
1286
+
1287
+ value = normalize_param_number(shape[key], key)
1288
+ raise ArgumentError, "Invalid #{shape.fetch(:kind)}#{shape_label(shape)}: #{key} must be non-negative" if value.negative?
1289
+ end
1290
+ end
1291
+
1292
+ def validate_shape_points!(shape, minimum:)
1293
+ points = Array(shape[:points])
1294
+ valid_points = points.count { |point| Array(point).length >= 2 }
1295
+ return if valid_points >= minimum
1296
+
1297
+ raise ArgumentError, "Invalid #{shape.fetch(:kind)}#{shape_label(shape)}: points must contain at least #{minimum} points"
1298
+ end
1299
+
1300
+ def shape_label(shape)
1301
+ shape[:id] ? " `#{shape[:id]}`" : ""
1302
+ end
1303
+
1304
+ def expand_custom_shape(renderer, options, shape_id:, cache: false)
1305
+ definition = custom_shape_definition(renderer)
1306
+ Vizcore::Shape.expand_custom_shape(
1307
+ definition.renderer,
1308
+ params: options,
1309
+ shape_id: shape_id,
1310
+ layer_name: @name,
1311
+ palette: Array(@params[:palette]),
1312
+ shape_name: definition.name || renderer,
1313
+ cache: cache
1314
+ )
1315
+ end
1316
+
1317
+ def custom_shape_definition(renderer)
1318
+ return Vizcore::Shape::Definition.new(name: nil, renderer: renderer) unless renderer.is_a?(Symbol) || renderer.is_a?(String)
1319
+
1320
+ Vizcore.resolve_shape(renderer) || raise(ArgumentError, "Unknown custom shape: #{renderer.inspect}. Register it with `Vizcore.register_shape #{renderer.inspect}, ShapeClass`.")
1321
+ end
1322
+
1323
+ def custom_shape_param_schema(renderer)
1324
+ return [] unless renderer.respond_to?(:shape_param_schema)
1325
+
1326
+ renderer.shape_param_schema.values.map(&:dup)
1327
+ end
1328
+
1329
+ def register_shape_id!(shape, shape_index)
1330
+ id = shape[:id]
1331
+ return if id.nil?
1332
+
1333
+ key = id.to_sym
1334
+ raise ArgumentError, "duplicate shape id: #{key.inspect}" if @shape_index_by_id.key?(key)
1335
+
1336
+ @shape_index_by_id[key] = shape_index
1337
+ end
1338
+
1339
+ def current_shape_transform
1340
+ mark_shape_schema_version!
1341
+ @current_shape[:transform] ||= {}
1342
+ end
1343
+
1344
+ def mark_shape_schema_version!
1345
+ @params[:shape_schema_version] ||= SHAPE_SCHEMA_VERSION
1346
+ end
1347
+
1348
+ def normalize_xy_args(args, x:, y:, name:)
1349
+ if args.length == 2
1350
+ return { x: normalize_param_number(args[0], :"#{name}.x"), y: normalize_param_number(args[1], :"#{name}.y") }
1351
+ end
1352
+
1353
+ if args.length == 1 && args.first.is_a?(Hash)
1354
+ values = args.first
1355
+ x = values.fetch(:x, values["x"])
1356
+ y = values.fetch(:y, values["y"])
1357
+ elsif args.any?
1358
+ raise ArgumentError, "#{name} expects x/y keywords or two numeric arguments"
1359
+ end
1360
+
1361
+ {
1362
+ x: normalize_param_number(x || 0, :"#{name}.x"),
1363
+ y: normalize_param_number(y || 0, :"#{name}.y")
1364
+ }
1365
+ end
1366
+
1367
+ def normalize_scale_args(value, x:, y:)
1368
+ if value.equal?(NO_ARGUMENT)
1369
+ return {
1370
+ x: normalize_param_number(x || 1, :"scale.x"),
1371
+ y: normalize_param_number(y || 1, :"scale.y")
1372
+ }
1373
+ end
1374
+
1375
+ raise ArgumentError, "scale accepts either a value or x/y keywords" unless x.nil? && y.nil?
1376
+
1377
+ normalize_param_number(value, :scale)
1378
+ end
1379
+
1380
+ def append_path_command(command, *values)
1381
+ raise ArgumentError, "#{command} is only available inside a path shape" unless @current_shape&.fetch(:kind) == :path
1382
+
1383
+ @current_shape[:commands] ||= []
1384
+ @current_shape[:commands] << [command, *values]
1385
+ end
1386
+
1387
+ def point_values(value)
1388
+ values = Array(value)
1389
+ raise ArgumentError, "point must contain x and y" unless values.length == 2
1390
+
1391
+ values
1392
+ end
1393
+
444
1394
  def with_shape_context(shape, shape_index)
445
1395
  previous_shape = @current_shape
446
1396
  previous_prefix = @shape_target_prefix
@@ -452,6 +1402,50 @@ module Vizcore
452
1402
  @shape_target_prefix = previous_prefix
453
1403
  end
454
1404
 
1405
+ def with_custom_shape_context(descriptor, descriptor_index)
1406
+ previous_custom_shape = @current_custom_shape
1407
+ previous_prefix = @custom_shape_target_prefix
1408
+ @current_custom_shape = descriptor
1409
+ @custom_shape_target_prefix = "custom_shapes.#{descriptor_index}"
1410
+ yield
1411
+ ensure
1412
+ @current_custom_shape = previous_custom_shape
1413
+ @custom_shape_target_prefix = previous_prefix
1414
+ end
1415
+
1416
+ def normalize_custom_shape_mapping(definition, options)
1417
+ if options.key?(:to)
1418
+ prefixed_options = options.dup
1419
+ prefixed_options[:to] = prefixed_custom_shape_target(prefixed_options[:to])
1420
+ return [definition, prefixed_options]
1421
+ end
1422
+
1423
+ mapping = definition.nil? ? options : Hash(definition)
1424
+ prefixed_mapping = mapping.each_with_object({}) do |(source, target), output|
1425
+ output[source] = prefix_custom_shape_target_value(target)
1426
+ end
1427
+ [prefixed_mapping, {}]
1428
+ end
1429
+
1430
+ def prefix_custom_shape_target_value(target)
1431
+ return prefixed_custom_shape_target(target) unless target.is_a?(Hash)
1432
+
1433
+ target.merge(to: prefixed_custom_shape_target(target.fetch(:to)))
1434
+ rescue KeyError
1435
+ target
1436
+ end
1437
+
1438
+ def prefixed_custom_shape_target(target)
1439
+ target_name = target.to_s
1440
+ return :"#{@custom_shape_target_prefix}.#{target_name}" if target_name.match?(/\A(?:params|style|transform)\./)
1441
+
1442
+ resolved_target = SHAPE_TARGET_ALIASES[target_name]
1443
+ return :"#{@custom_shape_target_prefix}.#{resolved_target}" if resolved_target
1444
+ return :"#{@custom_shape_target_prefix}.style.#{target_name}" if SHAPE_STYLE_KEYS.include?(target_name.to_sym)
1445
+
1446
+ :"#{@custom_shape_target_prefix}.params.#{target_name}"
1447
+ end
1448
+
455
1449
  def normalize_shape_mapping(definition, options)
456
1450
  if options.key?(:to)
457
1451
  prefixed_options = options.dup
@@ -475,7 +1469,9 @@ module Vizcore
475
1469
  end
476
1470
 
477
1471
  def prefixed_shape_target(target)
478
- :"#{@shape_target_prefix}.#{target}"
1472
+ target_name = target.to_s
1473
+ resolved_target = SHAPE_TARGET_ALIASES.fetch(target_name, target_name)
1474
+ :"#{@shape_target_prefix}.#{resolved_target}"
479
1475
  end
480
1476
 
481
1477
  def resolved_type
@@ -534,16 +1530,7 @@ module Vizcore
534
1530
  end
535
1531
 
536
1532
  def deep_dup(value)
537
- case value
538
- when Hash
539
- value.each_with_object({}) do |(key, entry), output|
540
- output[key] = deep_dup(entry)
541
- end
542
- when Array
543
- value.map { |entry| deep_dup(entry) }
544
- else
545
- value
546
- end
1533
+ Vizcore::DeepCopy.copy(value)
547
1534
  end
548
1535
 
549
1536
  def evaluate_transform_block(initial_options, &block)
@@ -577,19 +1564,26 @@ module Vizcore
577
1564
  raise ArgumentError, "param min must be less than or equal to max"
578
1565
  end
579
1566
 
580
- def normalize_transform(gain: nil, range: nil, min: nil, max: nil, curve: nil, attack: nil, release: nil, deadzone: nil)
1567
+ def normalize_transform(gain: nil, range: nil, min: nil, max: nil, curve: nil, attack: nil, release: nil, deadzone: nil, threshold: nil, hysteresis: nil, hold: nil, decay: nil, cooldown: nil, one_shot: nil, as: nil)
581
1568
  range_min, range_max = normalize_range(range, context: "mapping")
582
1569
  min = range_min if min.nil?
583
1570
  max = range_max if max.nil?
584
1571
 
585
1572
  output = {}
586
1573
  output[:deadzone] = normalize_non_negative_float(deadzone, :deadzone) unless deadzone.nil?
1574
+ output[:as] = normalize_mapping_mode(as) unless as.nil?
1575
+ output[:threshold] = normalize_float(threshold, :threshold) unless threshold.nil?
1576
+ output[:hysteresis] = normalize_non_negative_float(hysteresis, :hysteresis) unless hysteresis.nil?
587
1577
  output[:gain] = normalize_float(gain, :gain) unless gain.nil?
588
1578
  output[:min] = normalize_float(min, :min) unless min.nil?
589
1579
  output[:max] = normalize_float(max, :max) unless max.nil?
590
1580
  output[:curve] = normalize_curve(curve) unless curve.nil?
591
1581
  output[:attack] = clamp(normalize_float(attack, :attack), 0.0, 1.0) unless attack.nil?
592
1582
  output[:release] = clamp(normalize_float(release, :release), 0.0, 1.0) unless release.nil?
1583
+ output[:hold] = normalize_non_negative_float(hold, :hold) unless hold.nil?
1584
+ output[:decay] = clamp(normalize_float(decay, :decay), 0.0, 1.0) unless decay.nil?
1585
+ output[:cooldown] = normalize_non_negative_float(cooldown, :cooldown) unless cooldown.nil?
1586
+ output[:one_shot] = !!one_shot unless one_shot.nil?
593
1587
  output
594
1588
  end
595
1589
 
@@ -631,11 +1625,30 @@ module Vizcore
631
1625
 
632
1626
  def normalize_curve(value)
633
1627
  curve = value.to_sym
634
- return curve if %i[linear sqrt square ease_out].include?(curve)
1628
+ return curve if %i[linear sqrt square ease_out ease_in ease_in_out smoothstep exp log step].include?(curve)
635
1629
 
636
1630
  raise ArgumentError, "unsupported mapping curve: #{value.inspect}"
637
1631
  end
638
1632
 
1633
+ def normalize_mapping_mode(value)
1634
+ mode = value.to_sym
1635
+ return mode if %i[continuous trigger].include?(mode)
1636
+
1637
+ raise ArgumentError, "unsupported mapping mode: #{value.inspect}"
1638
+ end
1639
+
1640
+ def validate_strict_params!
1641
+ unknown = @params.keys.map(&:to_sym) - strict_allowed_params
1642
+ return if unknown.empty?
1643
+
1644
+ raise ArgumentError, "layer #{@name} has unknown params in strict mode: #{unknown.sort.join(', ')}"
1645
+ end
1646
+
1647
+ def strict_allowed_params
1648
+ catalog_params = Vizcore::LayerCatalog.params_for(resolved_type).keys
1649
+ (catalog_params + @param_schema.keys + STRICT_PARAM_ALLOWLIST).map(&:to_sym).uniq
1650
+ end
1651
+
639
1652
  def clamp(value, min, max)
640
1653
  [[value, min].max, max].min
641
1654
  end
@@ -646,6 +1659,44 @@ module Vizcore
646
1659
  **options
647
1660
  }
648
1661
  end
1662
+
1663
+ def shape_id_suggestions(key)
1664
+ return "" if @shape_index_by_id.empty?
1665
+
1666
+ candidates = @shape_index_by_id.keys
1667
+ .map do |shape_id|
1668
+ [shape_id, levenshtein_distance(shape_id.to_s, key.to_s)]
1669
+ end
1670
+ .select { |_, distance| distance <= 3 }
1671
+ .sort_by { |shape_id, distance| [distance, shape_id.to_s] }
1672
+ .first(3)
1673
+
1674
+ return "" if candidates.empty?
1675
+
1676
+ candidates.map { |shape_id, _| shape_id.inspect }.join(", ")
1677
+ end
1678
+
1679
+ def levenshtein_distance(a, b)
1680
+ prev = (0..b.length).to_a
1681
+ b_chars = b.bytes
1682
+ a_bytes = a.bytes
1683
+
1684
+ a_bytes.each_with_index do |codepoint_a, index_a|
1685
+ current = [index_a + 1]
1686
+ b_chars.each_with_index do |codepoint_b, index_b|
1687
+ cost = codepoint_a == codepoint_b ? 0 : 1
1688
+ current << [
1689
+ current[index_b] + 1,
1690
+ prev[index_b + 1] + 1,
1691
+ prev[index_b] + cost
1692
+ ].min
1693
+ end
1694
+
1695
+ prev = current
1696
+ end
1697
+
1698
+ prev[b.length]
1699
+ end
649
1700
  end
650
1701
  end
651
1702
  end