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
@@ -1,17 +1,68 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "mapping_transform_builder"
4
+ require_relative "reaction_builder"
5
+ require_relative "../shape"
6
+
3
7
  module Vizcore
4
8
  module DSL
5
9
  # Builder for one render layer in a scene.
6
10
  class LayerBuilder
11
+ NO_ARGUMENT = Object.new.freeze
12
+ SHAPE_SCHEMA_VERSION = 2
13
+ MAPPING_SOURCE_KINDS = %i[
14
+ amplitude frequency_band fft_spectrum onset kick snare hihat beat beat_confidence beat_pulse beat_count bpm
15
+ ].freeze
16
+ PATH_DEFAULT_DETAIL = 32
17
+ PATH_MIN_DETAIL = 4
18
+ PATH_MAX_DETAIL = 128
19
+ PATH_DEFAULT_MAX_SEGMENTS = 4096
20
+ SHAPE_TARGET_ALIASES = {
21
+ "translate_x" => "transform.translate.x",
22
+ "translate_y" => "transform.translate.y",
23
+ "rotate" => "transform.rotate",
24
+ "rotation" => "transform.rotate",
25
+ "scale" => "transform.scale",
26
+ "scale_x" => "transform.scale.x",
27
+ "scale_y" => "transform.scale.y",
28
+ "origin_x" => "transform.origin.x",
29
+ "origin_y" => "transform.origin.y"
30
+ }.freeze
31
+ SHAPE_STYLE_KEYS = Vizcore::Shape::STYLE_KEYS
32
+ SHAPE_TRANSFORM_KEYS = %i[translate rotate rotation scale origin].freeze
33
+
34
+ # Reference to an already declared shape, used by `map ... to: shape(:id).radius`.
35
+ class ShapeReference
36
+ def initialize(prefix)
37
+ @prefix = prefix
38
+ end
39
+
40
+ def method_missing(method_name, *args, &block)
41
+ return super unless args.empty? && block.nil?
42
+
43
+ target = SHAPE_TARGET_ALIASES.fetch(method_name.to_s, method_name.to_s)
44
+ :"#{@prefix}.#{target}"
45
+ end
46
+
47
+ def respond_to_missing?(_method_name, _include_private = false)
48
+ true
49
+ end
50
+ end
51
+
7
52
  # @param name [Symbol, String] layer identifier
8
- def initialize(name:)
53
+ # @param styles [Hash] reusable layer parameter styles
54
+ # @param defaults [Hash] default params applied before layer-specific values
55
+ def initialize(name:, styles: {}, defaults: {})
9
56
  @name = name.to_sym
57
+ @styles = styles
10
58
  @type = nil
11
59
  @shader = nil
12
60
  @glsl = nil
13
- @params = {}
61
+ @params = deep_dup(defaults)
62
+ @param_schema = {}
14
63
  @mappings = []
64
+ @shape_index_by_id = {}
65
+ @shape_group_stack = [{}]
15
66
  end
16
67
 
17
68
  # Evaluate a layer block.
@@ -29,10 +80,16 @@ module Vizcore
29
80
  @type = value.to_sym
30
81
  end
31
82
 
32
- # @param value [Symbol, String] built-in shader key
83
+ # @param value [Symbol, String] built-in shader key or custom GLSL path
84
+ # @param reload [Boolean, nil] accepted for custom shader path compatibility
33
85
  # @return [Symbol]
34
- def shader(value)
35
- @shader = value.to_sym
86
+ def shader(value, reload: nil)
87
+ if shader_path?(value)
88
+ @glsl = value.to_s
89
+ @params[:shader_reload] = !!reload unless reload.nil?
90
+ else
91
+ @shader = value.to_sym
92
+ end
36
93
  @type ||= :shader
37
94
  end
38
95
 
@@ -43,6 +100,174 @@ module Vizcore
43
100
  @type ||= :shader
44
101
  end
45
102
 
103
+ # @param path [String, Pathname] asset file path used by media-like layers
104
+ # @return [String]
105
+ def file(path)
106
+ @params[:file] = path.to_s
107
+ end
108
+
109
+ # Declare a 2D circle/ring primitive for a shape layer.
110
+ #
111
+ # @param options [Hash] shape params such as `count`, `radius`, `x`, and `y`
112
+ # @yield optional block evaluated in the shape context
113
+ # @return [Hash]
114
+ def circle(id = nil, **options, &block)
115
+ build_shape(:circle, shape_options(id, options), &block)
116
+ end
117
+
118
+ # Declare a 2D line primitive for a shape layer.
119
+ #
120
+ # @param options [Hash] shape params such as `x1`, `y1`, `x2`, and `y2`
121
+ # @yield optional block evaluated in the shape context
122
+ # @return [Hash]
123
+ def line(id = nil, **options, &block)
124
+ build_shape(:line, shape_options(id, options), &block)
125
+ end
126
+
127
+ # Declare a 2D rectangle primitive for a shape layer.
128
+ #
129
+ # @param id [Symbol, String, nil] optional shape identifier
130
+ # @param options [Hash] shape params such as `x`, `y`, `width`, `height`, and `radius`
131
+ # @yield optional block evaluated in the shape context
132
+ # @return [Hash]
133
+ def rect(id = nil, **options, &block)
134
+ build_shape(:rect, shape_options(id, options), schema_version: true, &block)
135
+ end
136
+
137
+ # Declare a closed polygon primitive for a shape layer.
138
+ #
139
+ # @param id [Symbol, String, nil] optional shape identifier
140
+ # @param options [Hash] shape params including `points`
141
+ # @yield optional block evaluated in the shape context
142
+ # @return [Hash]
143
+ def polygon(id = nil, **options, &block)
144
+ build_shape(:polygon, shape_options(id, options), schema_version: true, &block)
145
+ end
146
+
147
+ # Declare an open polyline primitive for a shape layer.
148
+ #
149
+ # @param id [Symbol, String, nil] optional shape identifier
150
+ # @param options [Hash] shape params including `points`
151
+ # @yield optional block evaluated in the shape context
152
+ # @return [Hash]
153
+ def polyline(id = nil, **options, &block)
154
+ build_shape(:polyline, shape_options(id, options).merge(closed: false), schema_version: true, &block)
155
+ end
156
+
157
+ # Declare a path primitive using SVG-like path commands.
158
+ #
159
+ # @param id [Symbol, String, nil] optional shape identifier
160
+ # @param options [Hash] path params such as `detail`
161
+ # @yield block containing path commands and shape styling
162
+ # @return [Hash]
163
+ def path(id = nil, **options, &block)
164
+ shape = shape_options(id, options)
165
+ shape[:commands] ||= []
166
+ build_shape(:path, shape, schema_version: true, &block)
167
+ end
168
+
169
+ # Declare a quadratic or cubic bezier curve. The serialized primitive is a path.
170
+ #
171
+ # @param id [Symbol, String, nil] optional shape identifier
172
+ # @param from [Array<Numeric>] start point
173
+ # @param to [Array<Numeric>] end point
174
+ # @param control [Array<Numeric>, nil] quadratic control point
175
+ # @param c1 [Array<Numeric>, nil] first cubic control point
176
+ # @param c2 [Array<Numeric>, nil] second cubic control point
177
+ # @param options [Hash] additional path params
178
+ # @yield optional block evaluated in the shape context
179
+ # @return [Hash]
180
+ def bezier(id = nil, from:, to:, control: nil, c1: nil, c2: nil, **options, &block)
181
+ commands = [["M", *point_values(from)]]
182
+ if control
183
+ commands << ["Q", *point_values(control), *point_values(to)]
184
+ elsif c1 && c2
185
+ commands << ["C", *point_values(c1), *point_values(c2), *point_values(to)]
186
+ else
187
+ raise ArgumentError, "bezier requires either :control or both :c1 and :c2"
188
+ end
189
+
190
+ build_shape(:path, shape_options(id, options).merge(commands: commands), schema_version: true, &block)
191
+ end
192
+
193
+ # Declare a star polygon primitive for a shape layer.
194
+ #
195
+ # @param id [Symbol, String, nil] optional shape identifier
196
+ # @param options [Hash] shape params such as `points`, `radius`, and `inner_radius`
197
+ # @yield optional block evaluated in the shape context
198
+ # @return [Hash]
199
+ def star(id = nil, **options, &block)
200
+ build_shape(:star, shape_options(id, options), schema_version: true, &block)
201
+ end
202
+
203
+ # Expand a registered Ruby custom shape into normal shape primitives.
204
+ #
205
+ # @param renderer [Symbol, String, Class, Module, #call] registered shape name or renderer
206
+ # @param options [Hash] custom shape params
207
+ # @yield optional block applied to each generated primitive
208
+ # @return [Array<Hash>]
209
+ def custom_shape(renderer, **options, &block)
210
+ mark_shape_schema_version!
211
+ shape_id = options.delete(:id)
212
+ dynamic = options.delete(:dynamic)
213
+ static = options.delete(:static)
214
+ raise ArgumentError, "custom_shape cannot be both static and dynamic" if dynamic && static
215
+
216
+ dynamic = true if static == false
217
+ return append_dynamic_custom_shape(renderer, options, shape_id: shape_id, &block) if dynamic
218
+
219
+ primitives = expand_custom_shape(renderer, options, shape_id: shape_id, cache: !!static)
220
+ raise ArgumentError, "custom_shape produced no primitives" if primitives.empty?
221
+ raise ArgumentError, "custom_shape id can only be assigned when one primitive is produced" if shape_id && primitives.length > 1
222
+
223
+ @type ||= :shape
224
+ @params[:shapes] ||= []
225
+ primitives.map do |primitive|
226
+ primitive[:id] ||= shape_id.to_sym if shape_id
227
+ append_expanded_shape(primitive, &block)
228
+ end
229
+ end
230
+
231
+ # Apply shared style and transform to shape primitives declared in the block.
232
+ #
233
+ # Group attributes are flattened into child primitives so the frontend only
234
+ # needs to render regular shape primitives.
235
+ #
236
+ # @param id [Symbol, String, nil] optional group identifier, currently documentation-only
237
+ # @param attrs [Hash] initial group style/transform attrs
238
+ # @yield shape declarations
239
+ # @return [Array<Hash>]
240
+ def group(_id = nil, **attrs, &block)
241
+ raise ArgumentError, "group requires a block" unless block
242
+
243
+ mark_shape_schema_version!
244
+ @type ||= :shape
245
+ @shape_group_stack << merge_shape_group(current_shape_group, normalize_shape_group(attrs))
246
+ instance_eval(&block)
247
+ @params[:shapes] || []
248
+ ensure
249
+ @shape_group_stack.pop if @shape_group_stack.length > 1
250
+ end
251
+
252
+ # Group shape primitives in a block for readability.
253
+ #
254
+ # @yield shape declarations
255
+ # @return [Array<Hash>]
256
+ def draw(&block)
257
+ @type ||= :shape
258
+ instance_eval(&block) if block
259
+ @params[:shapes] || []
260
+ end
261
+
262
+ # @param value [Symbol, String] input source for media-like layers
263
+ # @return [Symbol, Hash]
264
+ def source(value, **options)
265
+ source_name = value.to_sym
266
+ return mapping_source(source_name, **options) if options.any? || MAPPING_SOURCE_KINDS.include?(source_name)
267
+
268
+ @params[:source] = source_name
269
+ end
270
+
46
271
  # @param value [Integer] particle count or similar numeric parameter
47
272
  # @return [Integer]
48
273
  def count(value)
@@ -61,52 +286,480 @@ module Vizcore
61
286
  @params[:font_size] = Integer(value)
62
287
  end
63
288
 
289
+ # @param value [Numeric] extra spacing between text glyphs in pixels
290
+ # @return [Float]
291
+ def letter_spacing(value)
292
+ @params[:letter_spacing] = normalize_non_negative_param_number(value, :letter_spacing)
293
+ end
294
+
295
+ # @param value [Symbol, String] text alignment (`left`, `center`, `right`)
296
+ # @return [Symbol]
297
+ def align(value)
298
+ alignment = value.to_sym
299
+ raise ArgumentError, "unsupported text align: #{value.inspect}" unless %i[left center right].include?(alignment)
300
+
301
+ @params[:align] = alignment
302
+ end
303
+
304
+ # @param value [String] text font family
305
+ # @return [String]
306
+ def font(value)
307
+ @params[:font] = value.to_s
308
+ end
309
+
310
+ # @param value [String] text fill color
311
+ # @return [String]
312
+ def fill(value)
313
+ if @current_shape
314
+ @current_shape[:fill] = value.to_s
315
+ mark_shape_schema_version!
316
+ return @current_shape
317
+ end
318
+
319
+ if @current_custom_shape
320
+ current_custom_shape_style[:fill] = value.to_s
321
+ return @current_custom_shape
322
+ end
323
+
324
+ if in_shape_group?
325
+ current_shape_group[:fill] = value.to_s
326
+ return current_shape_group
327
+ end
328
+
329
+ @params[:color] = value.to_s
330
+ end
331
+
332
+ # @param width [Numeric, nil] text stroke width in pixels
333
+ # @param color [String, nil] text stroke color
334
+ # @return [Hash]
335
+ def stroke(value = NO_ARGUMENT, width: nil, color: nil)
336
+ if @current_shape
337
+ @current_shape[:stroke] = normalize_non_negative_param_number(value, :stroke) unless value.equal?(NO_ARGUMENT)
338
+ @current_shape[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
339
+ @current_shape[:stroke_color] = color.to_s unless color.nil?
340
+ return @current_shape
341
+ end
342
+
343
+ if @current_custom_shape
344
+ current_custom_shape_style[:stroke] = normalize_non_negative_param_number(value, :stroke) unless value.equal?(NO_ARGUMENT)
345
+ current_custom_shape_style[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
346
+ current_custom_shape_style[:stroke_color] = color.to_s unless color.nil?
347
+ return @current_custom_shape
348
+ end
349
+
350
+ if in_shape_group?
351
+ current_shape_group[:stroke] = normalize_non_negative_param_number(value, :stroke) unless value.equal?(NO_ARGUMENT)
352
+ current_shape_group[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
353
+ current_shape_group[:stroke_color] = color.to_s unless color.nil?
354
+ return current_shape_group
355
+ end
356
+
357
+ @params[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
358
+ @params[:stroke_color] = color.to_s unless color.nil?
359
+ @params
360
+ end
361
+
362
+ # @param color [String, nil] text shadow color
363
+ # @param blur [Numeric, nil] text shadow blur in pixels
364
+ # @return [Hash]
365
+ def shadow(color: nil, blur: nil)
366
+ @params[:shadow_color] = color.to_s unless color.nil?
367
+ @params[:shadow_blur] = normalize_non_negative_param_number(blur, :shadow_blur) unless blur.nil?
368
+ @params
369
+ end
370
+
371
+ # @param value [Symbol, String] layer compositing mode
372
+ # @return [Symbol]
373
+ def blend(value)
374
+ if @current_shape
375
+ @current_shape[:blend] = value.to_sym
376
+ mark_shape_schema_version!
377
+ return @current_shape
378
+ end
379
+
380
+ if @current_custom_shape
381
+ current_custom_shape_style[:blend] = value.to_sym
382
+ return @current_custom_shape
383
+ end
384
+
385
+ if in_shape_group?
386
+ current_shape_group[:blend] = value.to_sym
387
+ return current_shape_group
388
+ end
389
+
390
+ @params[:blend] = value.to_sym
391
+ end
392
+
393
+ # Set layer or shape opacity.
394
+ #
395
+ # @param value [Numeric]
396
+ # @return [Float, Hash]
397
+ def opacity(value)
398
+ if @current_shape
399
+ @current_shape[:opacity] = normalize_param_number(value, :opacity)
400
+ mark_shape_schema_version!
401
+ return @current_shape
402
+ end
403
+
404
+ if @current_custom_shape
405
+ current_custom_shape_style[:opacity] = normalize_param_number(value, :opacity)
406
+ return @current_custom_shape
407
+ end
408
+
409
+ if in_shape_group?
410
+ 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)
411
+ return current_shape_group
412
+ end
413
+
414
+ @params[:opacity] = normalize_param_number(value, :opacity)
415
+ end
416
+
417
+ # Set a shape/layer translation transform.
418
+ #
419
+ # @param args [Array<Numeric>]
420
+ # @param x [Numeric, nil]
421
+ # @param y [Numeric, nil]
422
+ # @return [Hash]
423
+ def translate(*args, x: nil, y: nil)
424
+ values = normalize_xy_args(args, x: x, y: y, name: :translate)
425
+ if @current_shape
426
+ current_shape_transform[:translate] = values
427
+ return @current_shape
428
+ end
429
+
430
+ if @current_custom_shape
431
+ current_custom_shape_transform[:translate] = add_shape_xy(current_custom_shape_transform[:translate], values)
432
+ return @current_custom_shape
433
+ end
434
+
435
+ if in_shape_group?
436
+ current_shape_group_transform[:translate] = add_shape_xy(current_shape_group_transform[:translate], values)
437
+ return current_shape_group
438
+ end
439
+
440
+ @params[:translate] = values
441
+ end
442
+
443
+ # Set a shape/layer rotation transform in degrees.
444
+ #
445
+ # @param value [Numeric]
446
+ # @return [Float, Hash]
447
+ def rotate(value)
448
+ rotation = normalize_param_number(value, :rotate)
449
+ if @current_shape
450
+ current_shape_transform[:rotate] = rotation
451
+ return @current_shape
452
+ end
453
+
454
+ if @current_custom_shape
455
+ current_custom_shape_transform[:rotate] = normalize_param_number(current_custom_shape_transform[:rotate] || 0, :rotate) + rotation
456
+ return @current_custom_shape
457
+ end
458
+
459
+ if in_shape_group?
460
+ current_shape_group_transform[:rotate] = normalize_param_number(current_shape_group_transform[:rotate] || 0, :rotate) + rotation
461
+ return current_shape_group
462
+ end
463
+
464
+ @params[:rotate] = rotation
465
+ end
466
+
467
+ # Set a shape/layer scale transform.
468
+ #
469
+ # @param value [Numeric]
470
+ # @param x [Numeric, nil]
471
+ # @param y [Numeric, nil]
472
+ # @return [Float, Hash]
473
+ def scale(value = NO_ARGUMENT, x: nil, y: nil)
474
+ scale_value = normalize_scale_args(value, x: x, y: y)
475
+ if @current_shape
476
+ current_shape_transform[:scale] = scale_value
477
+ return @current_shape
478
+ end
479
+
480
+ if @current_custom_shape
481
+ current_custom_shape_transform[:scale] = multiply_shape_scale(current_custom_shape_transform[:scale], scale_value)
482
+ return @current_custom_shape
483
+ end
484
+
485
+ if in_shape_group?
486
+ current_shape_group_transform[:scale] = multiply_shape_scale(current_shape_group_transform[:scale], scale_value)
487
+ return current_shape_group
488
+ end
489
+
490
+ @params[:scale] = scale_value
491
+ end
492
+
493
+ # Set a shape/layer transform origin.
494
+ #
495
+ # @param args [Array<Numeric>]
496
+ # @param x [Numeric, nil]
497
+ # @param y [Numeric, nil]
498
+ # @return [Hash]
499
+ def origin(*args, x: nil, y: nil)
500
+ values = normalize_xy_args(args, x: x, y: y, name: :origin)
501
+ if @current_shape
502
+ current_shape_transform[:origin] = values
503
+ return @current_shape
504
+ end
505
+
506
+ if @current_custom_shape
507
+ current_custom_shape_transform[:origin] = values
508
+ return @current_custom_shape
509
+ end
510
+
511
+ if in_shape_group?
512
+ current_shape_group_transform[:origin] = values
513
+ return current_shape_group
514
+ end
515
+
516
+ @params[:origin] = values
517
+ end
518
+
519
+ # Return a reference object for mapping to a named shape.
520
+ #
521
+ # @param id [Symbol, String]
522
+ # @return [ShapeReference]
523
+ def shape(id)
524
+ key = id.to_sym
525
+ index = @shape_index_by_id.fetch(key) { raise ArgumentError, "unknown shape id: #{key.inspect}" }
526
+ ShapeReference.new("shapes.#{index}")
527
+ end
528
+
529
+ def move_to(x, y)
530
+ append_path_command("M", x, y)
531
+ end
532
+
533
+ def line_to(x, y)
534
+ append_path_command("L", x, y)
535
+ end
536
+
537
+ def quad_to(cx, cy, x, y)
538
+ append_path_command("Q", cx, cy, x, y)
539
+ end
540
+
541
+ def cubic_to(c1x, c1y, c2x, c2y, x, y)
542
+ append_path_command("C", c1x, c1y, c2x, c2y, x, y)
543
+ end
544
+
545
+ def horizontal_to(x)
546
+ append_path_command("H", x)
547
+ end
548
+
549
+ def vertical_to(y)
550
+ append_path_command("V", y)
551
+ end
552
+
553
+ def arc_to(rx, ry, rotation, large_arc, sweep, x, y)
554
+ append_path_command("A", rx, ry, rotation, large_arc, sweep, x, y)
555
+ end
556
+
557
+ def close
558
+ append_path_command("Z")
559
+ end
560
+
561
+ # Store an ordered color palette for this layer.
562
+ #
563
+ # @param colors [Array<String, Array<String>>] color values such as "#00ffff"
564
+ # @raise [ArgumentError] when no non-blank colors are supplied
565
+ # @return [Array<String>]
566
+ def palette(*colors)
567
+ @params[:palette] = normalize_palette(colors)
568
+ end
569
+
570
+ # Apply a named style by merging its params into this layer.
571
+ #
572
+ # @param name [Symbol, String] style identifier
573
+ # @raise [ArgumentError] when the style is unknown
574
+ # @return [Hash] applied style params
575
+ def use_style(name)
576
+ style_name = name.to_sym
577
+ style_params = @styles.fetch(style_name) { raise ArgumentError, "unknown style: #{style_name}" }
578
+ @params.merge!(deep_dup(style_params))
579
+ end
580
+
581
+ # Declare numeric metadata for a shader/layer parameter.
582
+ #
583
+ # @param name [Symbol, String] parameter name exposed as `u_param_<name>` for shaders
584
+ # @param default [Numeric, nil] default value stored in layer params
585
+ # @param range [Range, Array, nil] allowed numeric range
586
+ # @param min [Numeric, nil] allowed minimum when `range` is not used
587
+ # @param max [Numeric, nil] allowed maximum when `range` is not used
588
+ # @param step [Numeric, nil] preferred UI step
589
+ # @return [Hash]
590
+ def param(name, default: nil, range: nil, min: nil, max: nil, step: nil)
591
+ key = normalize_param_name(name)
592
+ range_min, range_max = normalize_range(range, context: "param")
593
+ min = range_min if min.nil?
594
+ max = range_max if max.nil?
595
+
596
+ metadata = { name: key }
597
+ metadata[:default] = normalize_param_number(default, :default) unless default.nil?
598
+ metadata[:min] = normalize_param_number(min, :min) unless min.nil?
599
+ metadata[:max] = normalize_param_number(max, :max) unless max.nil?
600
+ metadata[:step] = normalize_param_number(step, :step) unless step.nil?
601
+ validate_param_range!(metadata)
602
+
603
+ @params[key] = metadata[:default] if metadata.key?(:default)
604
+ @param_schema[key] = metadata
605
+ end
606
+
64
607
  # Map analysis source(s) to layer parameter target(s).
65
608
  #
66
- # @param definition [Hash] mapping pairs (`source` => `target`)
609
+ # @param definition [Hash, Symbol, String] mapping pairs or a single source
67
610
  # @raise [ArgumentError] when the mapping is empty or invalid
68
611
  # @return [void]
69
- def map(definition)
70
- mapping = Hash(definition)
612
+ def map(definition = nil, **options, &block)
613
+ definition, options = normalize_custom_shape_mapping(definition, options) if @custom_shape_target_prefix
614
+ definition, options = normalize_shape_mapping(definition, options) if @shape_target_prefix
615
+
616
+ if options.key?(:to)
617
+ transform_options = options.dup
618
+ to = transform_options.delete(:to)
619
+ transform_options = evaluate_transform_block(transform_options, &block) if block
620
+ @mappings << build_mapping(
621
+ source: normalize_source(definition),
622
+ target: to,
623
+ transform: normalize_transform(**transform_options)
624
+ )
625
+ return
626
+ end
627
+
628
+ mapping = definition.nil? ? options : Hash(definition)
71
629
  raise ArgumentError, "map requires at least one mapping pair" if mapping.empty?
630
+ raise ArgumentError, "map block syntax supports one mapping pair" if block && mapping.length != 1
72
631
 
73
632
  mapping.each do |source, target|
74
- @mappings << {
75
- source: normalize_source(source),
76
- target: target.to_sym
77
- }
633
+ target_name, transform = normalize_target(target)
634
+ transform = normalize_transform(**evaluate_transform_block(transform, &block)) if block
635
+ @mappings << build_mapping(source: normalize_source(source), target: target_name, transform: transform)
78
636
  end
79
637
  end
80
638
 
639
+ # High-level mapping DSL for describing audio reactions inside a layer.
640
+ #
641
+ # @param source_value [Hash, Symbol, String] analysis source descriptor
642
+ # @yield Reaction block with `change` and `trigger`
643
+ # @raise [ArgumentError] when no reaction block is provided
644
+ # @return [void]
645
+ def react_to(source_value, &block)
646
+ raise ArgumentError, "react_to requires a block" unless block
647
+
648
+ source_descriptor = normalize_source(source_value)
649
+ reaction = ReactionBuilder.new(
650
+ mapping_factory: lambda do |target, transform_options|
651
+ build_mapping(
652
+ source: source_descriptor,
653
+ target: target,
654
+ transform: normalize_transform(**transform_options)
655
+ )
656
+ end
657
+ )
658
+ @mappings.concat(reaction.evaluate(&block))
659
+ end
660
+
81
661
  # @return [Hash] source descriptor for overall amplitude
82
662
  def amplitude
83
- source(:amplitude)
663
+ mapping_source(:amplitude)
84
664
  end
85
665
 
86
666
  # @param name [Symbol, String] band key (`sub`, `low`, `mid`, `high`)
87
667
  # @return [Hash] source descriptor for a frequency band
88
668
  def frequency_band(name)
89
- source(:frequency_band, band: name.to_sym)
669
+ mapping_source(:frequency_band, band: name.to_sym)
670
+ end
671
+
672
+ # @return [Hash] source descriptor for the sub-bass frequency band
673
+ def sub
674
+ frequency_band(:sub)
675
+ end
676
+
677
+ # @return [Hash] source descriptor for the low/bass frequency band
678
+ def low
679
+ frequency_band(:low)
680
+ end
681
+
682
+ # @return [Hash] source descriptor for the low/bass frequency band
683
+ def bass
684
+ frequency_band(:low)
685
+ end
686
+
687
+ # @return [Hash] source descriptor for the mid frequency band
688
+ def mid
689
+ frequency_band(:mid)
690
+ end
691
+
692
+ # @return [Hash] source descriptor for the high frequency band
693
+ def high
694
+ frequency_band(:high)
695
+ end
696
+
697
+ # @return [Hash] source descriptor for the high/treble frequency band
698
+ def treble
699
+ frequency_band(:high)
90
700
  end
91
701
 
92
702
  # @return [Hash] source descriptor for FFT spectrum array
93
703
  def fft_spectrum
94
- source(:fft_spectrum)
704
+ mapping_source(:fft_spectrum)
705
+ end
706
+
707
+ # @param band [Symbol, String, nil] optional band-specific onset key
708
+ # @return [Hash] source descriptor for positive audio feature changes
709
+ def onset(band = nil)
710
+ options = band.nil? ? {} : { band: band.to_sym }
711
+ mapping_source(:onset, **options)
712
+ end
713
+
714
+ # @return [Hash] source descriptor for low-band percussive confidence
715
+ def kick(value = NO_ARGUMENT)
716
+ return @params[:kick] = value unless value.equal?(NO_ARGUMENT)
717
+
718
+ mapping_source(:kick)
719
+ end
720
+
721
+ # @return [Hash] source descriptor for mid-band percussive confidence
722
+ def snare(value = NO_ARGUMENT)
723
+ return @params[:snare] = value unless value.equal?(NO_ARGUMENT)
724
+
725
+ mapping_source(:snare)
726
+ end
727
+
728
+ # @return [Hash] source descriptor for high-band percussive confidence
729
+ def hihat(value = NO_ARGUMENT)
730
+ return @params[:hihat] = value unless value.equal?(NO_ARGUMENT)
731
+
732
+ mapping_source(:hihat)
95
733
  end
96
734
 
97
735
  # @return [Hash] source descriptor for beat trigger
98
736
  def beat?
99
- source(:beat)
737
+ mapping_source(:beat)
738
+ end
739
+
740
+ # @return [Hash] source descriptor for beat trigger
741
+ def beat
742
+ beat?
743
+ end
744
+
745
+ # @return [Hash] source descriptor for beat detector confidence
746
+ def beat_confidence
747
+ mapping_source(:beat_confidence)
748
+ end
749
+
750
+ # @return [Hash] source descriptor for beat pulse decay value
751
+ def beat_pulse
752
+ mapping_source(:beat_pulse)
100
753
  end
101
754
 
102
755
  # @return [Hash] source descriptor for beat counter
103
756
  def beat_count
104
- source(:beat_count)
757
+ mapping_source(:beat_count)
105
758
  end
106
759
 
107
760
  # @return [Hash] source descriptor for estimated BPM
108
761
  def bpm
109
- source(:bpm)
762
+ mapping_source(:bpm)
110
763
  end
111
764
 
112
765
  # @return [Hash] serialized layer payload
@@ -118,6 +771,7 @@ module Vizcore
118
771
  }
119
772
  layer[:shader] = @shader if @shader
120
773
  layer[:glsl] = @glsl if @glsl
774
+ layer[:param_schema] = @param_schema.values.map(&:dup) unless @param_schema.empty?
121
775
  layer[:mappings] = @mappings.map { |mapping| mapping.dup } unless @mappings.empty?
122
776
  layer
123
777
  end
@@ -125,6 +779,21 @@ module Vizcore
125
779
  # Stores dynamic one-argument setters into `params`.
126
780
  # @api private
127
781
  def method_missing(method_name, *args, &block)
782
+ if @current_shape && block.nil? && args.length == 1
783
+ @current_shape[method_name.to_sym] = args.first
784
+ return args.first
785
+ end
786
+
787
+ if @current_custom_shape && block.nil? && args.length == 1
788
+ @current_custom_shape[:params][method_name.to_sym] = args.first
789
+ return args.first
790
+ end
791
+
792
+ if in_shape_group? && block.nil? && args.length == 1
793
+ current_shape_group[method_name.to_sym] = args.first
794
+ return args.first
795
+ end
796
+
128
797
  if block.nil? && args.length == 1
129
798
  @params[method_name.to_sym] = args.first
130
799
  return args.first
@@ -134,11 +803,469 @@ module Vizcore
134
803
  end
135
804
 
136
805
  def respond_to_missing?(method_name, include_private = false)
137
- @params.key?(method_name.to_sym) || super
806
+ !!@current_custom_shape || @params.key?(method_name.to_sym) || super
138
807
  end
139
808
 
140
809
  private
141
810
 
811
+ def build_shape(kind, options, schema_version: false, &block)
812
+ @type ||= :shape
813
+ mark_shape_schema_version! if schema_version
814
+ shape = normalize_shape(kind, options)
815
+ @params[:shapes] ||= []
816
+ shape_index = @params[:shapes].length
817
+ register_shape_id!(shape, shape_index)
818
+ @params[:shapes] << shape
819
+
820
+ with_shape_context(shape, shape_index) do
821
+ instance_eval(&block) if block
822
+ end
823
+ apply_current_shape_group!(shape)
824
+ validate_shape!(shape)
825
+
826
+ shape
827
+ end
828
+
829
+ def append_expanded_shape(shape, &block)
830
+ shape_index = @params[:shapes].length
831
+ register_shape_id!(shape, shape_index)
832
+ @params[:shapes] << shape
833
+
834
+ with_shape_context(shape, shape_index) do
835
+ instance_eval(&block) if block
836
+ end
837
+ apply_current_shape_group!(shape)
838
+ validate_shape!(shape)
839
+
840
+ shape
841
+ end
842
+
843
+ def append_dynamic_custom_shape(renderer, options, shape_id:, &block)
844
+ definition = custom_shape_definition(renderer)
845
+ @type ||= :shape
846
+ @params[:custom_shapes] ||= []
847
+ descriptor = {
848
+ name: definition.name || renderer,
849
+ renderer: definition.renderer,
850
+ params: deep_dup(options),
851
+ style: {},
852
+ transform: {},
853
+ dynamic: true
854
+ }
855
+ param_schema = custom_shape_param_schema(definition.renderer)
856
+ descriptor[:param_schema] = param_schema unless param_schema.empty?
857
+ descriptor[:shape_id] = shape_id.to_sym if shape_id
858
+ descriptor_index = @params[:custom_shapes].length
859
+ @params[:custom_shapes] << descriptor
860
+
861
+ with_custom_shape_context(descriptor, descriptor_index) do
862
+ instance_eval(&block) if block
863
+ end
864
+ apply_current_shape_group_to_custom_shape!(descriptor)
865
+
866
+ descriptor
867
+ end
868
+
869
+ def shape_options(id, options)
870
+ return options if id.nil?
871
+
872
+ raise ArgumentError, "shape id specified twice" if options.key?(:id)
873
+
874
+ options.merge(id: id.to_sym)
875
+ end
876
+
877
+ def normalize_shape(kind, options)
878
+ shape = { kind: kind.to_sym }
879
+ options.each do |key, value|
880
+ shape[key.to_sym] = value
881
+ end
882
+ shape
883
+ end
884
+
885
+ def normalize_shape_group(attrs)
886
+ attrs.each_with_object({}) do |(key, value), group|
887
+ symbol_key = key.to_sym
888
+ if SHAPE_TRANSFORM_KEYS.include?(symbol_key)
889
+ transform_key = symbol_key == :rotation ? :rotate : symbol_key
890
+ group[:transform] ||= {}
891
+ group[:transform][transform_key] = value
892
+ else
893
+ group[symbol_key] = value
894
+ end
895
+ end
896
+ end
897
+
898
+ def merge_shape_group(parent, child)
899
+ output = deep_dup(parent)
900
+ child.each do |key, value|
901
+ if key == :transform
902
+ output[:transform] = compose_shape_transform(output[:transform], value)
903
+ elsif key == :opacity && output.key?(:opacity)
904
+ output[:opacity] = normalize_param_number(output[:opacity], :opacity) * normalize_param_number(value, :opacity)
905
+ else
906
+ output[key] = deep_dup(value)
907
+ end
908
+ end
909
+ output
910
+ end
911
+
912
+ def apply_current_shape_group!(shape)
913
+ group = current_shape_group
914
+ return shape if group.empty?
915
+
916
+ SHAPE_STYLE_KEYS.each do |key|
917
+ next unless group.key?(key)
918
+
919
+ if key == :opacity && shape.key?(:opacity)
920
+ shape[:opacity] = normalize_param_number(group[:opacity], :opacity) * normalize_param_number(shape[:opacity], :opacity)
921
+ else
922
+ shape[key] = deep_dup(group[key]) unless shape.key?(key)
923
+ end
924
+ end
925
+ shape[:transform] = compose_shape_transform(group[:transform], shape[:transform]) if group[:transform]
926
+ shape
927
+ end
928
+
929
+ def apply_current_shape_group_to_custom_shape!(descriptor)
930
+ group = current_shape_group
931
+ return descriptor if group.empty?
932
+
933
+ style = descriptor[:style] ||= {}
934
+ SHAPE_STYLE_KEYS.each do |key|
935
+ next unless group.key?(key)
936
+
937
+ if key == :opacity && style.key?(:opacity)
938
+ style[:opacity] = normalize_param_number(group[:opacity], :opacity) * normalize_param_number(style[:opacity], :opacity)
939
+ else
940
+ style[key] = deep_dup(group[key]) unless style.key?(key)
941
+ end
942
+ end
943
+ descriptor[:transform] = compose_shape_transform(group[:transform], descriptor[:transform]) if group[:transform]
944
+ descriptor
945
+ end
946
+
947
+ def compose_shape_transform(parent, child)
948
+ return deep_dup(child || {}) unless parent
949
+
950
+ child ||= {}
951
+ output = deep_dup(parent)
952
+ output[:translate] = add_shape_xy(parent[:translate], child[:translate]) if child.key?(:translate)
953
+ output[:origin] = child[:origin] if child.key?(:origin)
954
+ output[:rotate] = normalize_param_number(parent[:rotate] || 0, :rotate) + normalize_param_number(child[:rotate] || 0, :rotate) if child.key?(:rotate)
955
+ output[:scale] = multiply_shape_scale(parent[:scale], child[:scale]) if child.key?(:scale)
956
+ output
957
+ end
958
+
959
+ def add_shape_xy(parent, child)
960
+ parent ||= {}
961
+ child ||= {}
962
+ {
963
+ x: normalize_param_number(parent[:x] || parent["x"] || 0, :"translate.x") + normalize_param_number(child[:x] || child["x"] || 0, :"translate.x"),
964
+ y: normalize_param_number(parent[:y] || parent["y"] || 0, :"translate.y") + normalize_param_number(child[:y] || child["y"] || 0, :"translate.y")
965
+ }
966
+ end
967
+
968
+ def multiply_shape_scale(parent, child)
969
+ parent = shape_scale_pair(parent)
970
+ child = shape_scale_pair(child)
971
+ { x: parent[:x] * child[:x], y: parent[:y] * child[:y] }
972
+ end
973
+
974
+ def shape_scale_pair(value)
975
+ 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)
976
+
977
+ scale = normalize_param_number(value || 1, :scale)
978
+ { x: scale, y: scale }
979
+ end
980
+
981
+ def current_shape_group
982
+ @shape_group_stack.last
983
+ end
984
+
985
+ def current_shape_group_transform
986
+ current_shape_group[:transform] ||= {}
987
+ end
988
+
989
+ def current_custom_shape_style
990
+ @current_custom_shape[:style] ||= {}
991
+ end
992
+
993
+ def current_custom_shape_transform
994
+ @current_custom_shape[:transform] ||= {}
995
+ end
996
+
997
+ def in_shape_group?
998
+ @shape_group_stack.length > 1
999
+ end
1000
+
1001
+ def validate_shape!(shape)
1002
+ validate_non_negative_shape_numbers!(shape)
1003
+ case shape.fetch(:kind)
1004
+ when :polygon
1005
+ validate_shape_points!(shape, minimum: 3)
1006
+ when :polyline
1007
+ validate_shape_points!(shape, minimum: 2)
1008
+ when :path
1009
+ validate_path_shape!(shape)
1010
+ end
1011
+ end
1012
+
1013
+ def validate_path_shape!(shape)
1014
+ commands = Array(shape[:commands])
1015
+ raise ArgumentError, "Invalid path#{shape_label(shape)}: commands must not be empty" if commands.empty?
1016
+
1017
+ detail = normalized_path_integer(shape, :detail, PATH_DEFAULT_DETAIL).clamp(PATH_MIN_DETAIL, PATH_MAX_DETAIL)
1018
+ max_segments = normalized_path_integer(shape, :max_segments, PATH_DEFAULT_MAX_SEGMENTS)
1019
+ validate_path_tolerance!(shape)
1020
+
1021
+ segment_count = estimated_path_segments(commands, detail)
1022
+ return if segment_count <= max_segments
1023
+
1024
+ raise ArgumentError,
1025
+ "Invalid path#{shape_label(shape)}: max_segments exceeded (#{segment_count} > #{max_segments})"
1026
+ end
1027
+
1028
+ def normalized_path_integer(shape, key, default)
1029
+ value = shape.key?(key) ? shape[key] : default
1030
+ numeric = Integer(value)
1031
+ raise ArgumentError if numeric <= 0
1032
+
1033
+ numeric
1034
+ rescue ArgumentError, TypeError
1035
+ raise ArgumentError, "Invalid path#{shape_label(shape)}: #{key} must be a positive integer"
1036
+ end
1037
+
1038
+ def validate_path_tolerance!(shape)
1039
+ return unless shape.key?(:tolerance)
1040
+
1041
+ value = normalize_param_number(shape[:tolerance], :tolerance)
1042
+ return unless value.negative?
1043
+
1044
+ raise ArgumentError, "Invalid path#{shape_label(shape)}: tolerance must be non-negative"
1045
+ end
1046
+
1047
+ def estimated_path_segments(commands, detail)
1048
+ current = false
1049
+ subpath_start = false
1050
+ commands.sum do |entry|
1051
+ command, *values = Array(entry)
1052
+ case command.to_s.upcase
1053
+ when "M"
1054
+ current = values.length >= 2
1055
+ subpath_start = current
1056
+ 0
1057
+ when "L"
1058
+ current && values.length >= 2 ? 1 : 0
1059
+ when "H", "V"
1060
+ current && values.length >= 1 ? 1 : 0
1061
+ when "Q"
1062
+ current && values.length >= 4 ? detail : 0
1063
+ when "C"
1064
+ current && values.length >= 6 ? detail : 0
1065
+ when "A"
1066
+ current && values.length >= 7 ? detail : 0
1067
+ when "Z"
1068
+ current && subpath_start ? 1 : 0
1069
+ else
1070
+ 0
1071
+ end
1072
+ end
1073
+ end
1074
+
1075
+ def validate_non_negative_shape_numbers!(shape)
1076
+ %i[radius width height stroke_width inner_radius].each do |key|
1077
+ next unless shape.key?(key)
1078
+
1079
+ value = normalize_param_number(shape[key], key)
1080
+ raise ArgumentError, "Invalid #{shape.fetch(:kind)}#{shape_label(shape)}: #{key} must be non-negative" if value.negative?
1081
+ end
1082
+ end
1083
+
1084
+ def validate_shape_points!(shape, minimum:)
1085
+ points = Array(shape[:points])
1086
+ valid_points = points.count { |point| Array(point).length >= 2 }
1087
+ return if valid_points >= minimum
1088
+
1089
+ raise ArgumentError, "Invalid #{shape.fetch(:kind)}#{shape_label(shape)}: points must contain at least #{minimum} points"
1090
+ end
1091
+
1092
+ def shape_label(shape)
1093
+ shape[:id] ? " `#{shape[:id]}`" : ""
1094
+ end
1095
+
1096
+ def expand_custom_shape(renderer, options, shape_id:, cache: false)
1097
+ definition = custom_shape_definition(renderer)
1098
+ Vizcore::Shape.expand_custom_shape(
1099
+ definition.renderer,
1100
+ params: options,
1101
+ shape_id: shape_id,
1102
+ layer_name: @name,
1103
+ palette: Array(@params[:palette]),
1104
+ shape_name: definition.name || renderer,
1105
+ cache: cache
1106
+ )
1107
+ end
1108
+
1109
+ def custom_shape_definition(renderer)
1110
+ return Vizcore::Shape::Definition.new(name: nil, renderer: renderer) unless renderer.is_a?(Symbol) || renderer.is_a?(String)
1111
+
1112
+ Vizcore.resolve_shape(renderer) || raise(ArgumentError, "Unknown custom shape: #{renderer.inspect}. Register it with `Vizcore.register_shape #{renderer.inspect}, ShapeClass`.")
1113
+ end
1114
+
1115
+ def custom_shape_param_schema(renderer)
1116
+ return [] unless renderer.respond_to?(:shape_param_schema)
1117
+
1118
+ renderer.shape_param_schema.values.map(&:dup)
1119
+ end
1120
+
1121
+ def register_shape_id!(shape, shape_index)
1122
+ id = shape[:id]
1123
+ return if id.nil?
1124
+
1125
+ key = id.to_sym
1126
+ raise ArgumentError, "duplicate shape id: #{key.inspect}" if @shape_index_by_id.key?(key)
1127
+
1128
+ @shape_index_by_id[key] = shape_index
1129
+ end
1130
+
1131
+ def current_shape_transform
1132
+ mark_shape_schema_version!
1133
+ @current_shape[:transform] ||= {}
1134
+ end
1135
+
1136
+ def mark_shape_schema_version!
1137
+ @params[:shape_schema_version] ||= SHAPE_SCHEMA_VERSION
1138
+ end
1139
+
1140
+ def normalize_xy_args(args, x:, y:, name:)
1141
+ if args.length == 2
1142
+ return { x: normalize_param_number(args[0], :"#{name}.x"), y: normalize_param_number(args[1], :"#{name}.y") }
1143
+ end
1144
+
1145
+ if args.length == 1 && args.first.is_a?(Hash)
1146
+ values = args.first
1147
+ x = values.fetch(:x, values["x"])
1148
+ y = values.fetch(:y, values["y"])
1149
+ elsif args.any?
1150
+ raise ArgumentError, "#{name} expects x/y keywords or two numeric arguments"
1151
+ end
1152
+
1153
+ {
1154
+ x: normalize_param_number(x || 0, :"#{name}.x"),
1155
+ y: normalize_param_number(y || 0, :"#{name}.y")
1156
+ }
1157
+ end
1158
+
1159
+ def normalize_scale_args(value, x:, y:)
1160
+ if value.equal?(NO_ARGUMENT)
1161
+ return {
1162
+ x: normalize_param_number(x || 1, :"scale.x"),
1163
+ y: normalize_param_number(y || 1, :"scale.y")
1164
+ }
1165
+ end
1166
+
1167
+ raise ArgumentError, "scale accepts either a value or x/y keywords" unless x.nil? && y.nil?
1168
+
1169
+ normalize_param_number(value, :scale)
1170
+ end
1171
+
1172
+ def append_path_command(command, *values)
1173
+ raise ArgumentError, "#{command} is only available inside a path shape" unless @current_shape&.fetch(:kind) == :path
1174
+
1175
+ @current_shape[:commands] ||= []
1176
+ @current_shape[:commands] << [command, *values]
1177
+ end
1178
+
1179
+ def point_values(value)
1180
+ values = Array(value)
1181
+ raise ArgumentError, "point must contain x and y" unless values.length == 2
1182
+
1183
+ values
1184
+ end
1185
+
1186
+ def with_shape_context(shape, shape_index)
1187
+ previous_shape = @current_shape
1188
+ previous_prefix = @shape_target_prefix
1189
+ @current_shape = shape
1190
+ @shape_target_prefix = "shapes.#{shape_index}"
1191
+ yield
1192
+ ensure
1193
+ @current_shape = previous_shape
1194
+ @shape_target_prefix = previous_prefix
1195
+ end
1196
+
1197
+ def with_custom_shape_context(descriptor, descriptor_index)
1198
+ previous_custom_shape = @current_custom_shape
1199
+ previous_prefix = @custom_shape_target_prefix
1200
+ @current_custom_shape = descriptor
1201
+ @custom_shape_target_prefix = "custom_shapes.#{descriptor_index}"
1202
+ yield
1203
+ ensure
1204
+ @current_custom_shape = previous_custom_shape
1205
+ @custom_shape_target_prefix = previous_prefix
1206
+ end
1207
+
1208
+ def normalize_custom_shape_mapping(definition, options)
1209
+ if options.key?(:to)
1210
+ prefixed_options = options.dup
1211
+ prefixed_options[:to] = prefixed_custom_shape_target(prefixed_options[:to])
1212
+ return [definition, prefixed_options]
1213
+ end
1214
+
1215
+ mapping = definition.nil? ? options : Hash(definition)
1216
+ prefixed_mapping = mapping.each_with_object({}) do |(source, target), output|
1217
+ output[source] = prefix_custom_shape_target_value(target)
1218
+ end
1219
+ [prefixed_mapping, {}]
1220
+ end
1221
+
1222
+ def prefix_custom_shape_target_value(target)
1223
+ return prefixed_custom_shape_target(target) unless target.is_a?(Hash)
1224
+
1225
+ target.merge(to: prefixed_custom_shape_target(target.fetch(:to)))
1226
+ rescue KeyError
1227
+ target
1228
+ end
1229
+
1230
+ def prefixed_custom_shape_target(target)
1231
+ target_name = target.to_s
1232
+ return :"#{@custom_shape_target_prefix}.#{target_name}" if target_name.match?(/\A(?:params|style|transform)\./)
1233
+
1234
+ resolved_target = SHAPE_TARGET_ALIASES[target_name]
1235
+ return :"#{@custom_shape_target_prefix}.#{resolved_target}" if resolved_target
1236
+ return :"#{@custom_shape_target_prefix}.style.#{target_name}" if SHAPE_STYLE_KEYS.include?(target_name.to_sym)
1237
+
1238
+ :"#{@custom_shape_target_prefix}.params.#{target_name}"
1239
+ end
1240
+
1241
+ def normalize_shape_mapping(definition, options)
1242
+ if options.key?(:to)
1243
+ prefixed_options = options.dup
1244
+ prefixed_options[:to] = prefixed_shape_target(prefixed_options[:to])
1245
+ return [definition, prefixed_options]
1246
+ end
1247
+
1248
+ mapping = definition.nil? ? options : Hash(definition)
1249
+ prefixed_mapping = mapping.each_with_object({}) do |(source, target), output|
1250
+ output[source] = prefix_shape_target_value(target)
1251
+ end
1252
+ [prefixed_mapping, {}]
1253
+ end
1254
+
1255
+ def prefix_shape_target_value(target)
1256
+ return prefixed_shape_target(target) unless target.is_a?(Hash)
1257
+
1258
+ target.merge(to: prefixed_shape_target(target.fetch(:to)))
1259
+ rescue KeyError
1260
+ target
1261
+ end
1262
+
1263
+ def prefixed_shape_target(target)
1264
+ target_name = target.to_s
1265
+ resolved_target = SHAPE_TARGET_ALIASES.fetch(target_name, target_name)
1266
+ :"#{@shape_target_prefix}.#{resolved_target}"
1267
+ end
1268
+
142
1269
  def resolved_type
143
1270
  return @type if @type
144
1271
  return :shader if @shader || @glsl
@@ -146,17 +1273,24 @@ module Vizcore
146
1273
  :geometry
147
1274
  end
148
1275
 
1276
+ def shader_path?(value)
1277
+ return false if value.is_a?(Symbol)
1278
+
1279
+ path = value.to_s
1280
+ %w[.frag .glsl].include?(File.extname(path).downcase) || path.include?("/")
1281
+ end
1282
+
149
1283
  def normalize_source(source_value)
150
1284
  case source_value
151
1285
  when Hash
152
1286
  kind = source_value[:kind] || source_value["kind"]
153
1287
  raise ArgumentError, "mapping source hash must contain :kind" unless kind
154
1288
 
155
- source(kind.to_sym, **normalize_source_options(source_value))
1289
+ mapping_source(kind.to_sym, **normalize_source_options(source_value))
156
1290
  when Symbol
157
- source(source_value)
1291
+ mapping_source(source_value)
158
1292
  when String
159
- source(source_value.to_sym)
1293
+ mapping_source(source_value.to_sym)
160
1294
  else
161
1295
  raise ArgumentError, "unsupported mapping source: #{source_value.inspect}"
162
1296
  end
@@ -171,7 +1305,130 @@ module Vizcore
171
1305
  end
172
1306
  end
173
1307
 
174
- def source(kind, **options)
1308
+ def normalize_target(target)
1309
+ return [target.to_sym, {}] unless target.is_a?(Hash)
1310
+
1311
+ values = target.each_with_object({}) { |(key, value), output| output[key.to_sym] = value }
1312
+ to = values.delete(:to)
1313
+ raise ArgumentError, "mapping target hash must contain :to" unless to
1314
+
1315
+ [to.to_sym, normalize_transform(**values)]
1316
+ end
1317
+
1318
+ def build_mapping(source:, target:, transform: {})
1319
+ output = { source: source, target: target.to_sym }
1320
+ output[:transform] = transform unless transform.empty?
1321
+ output
1322
+ end
1323
+
1324
+ def deep_dup(value)
1325
+ case value
1326
+ when Hash
1327
+ value.each_with_object({}) do |(key, entry), output|
1328
+ output[key] = deep_dup(entry)
1329
+ end
1330
+ when Array
1331
+ value.map { |entry| deep_dup(entry) }
1332
+ else
1333
+ value
1334
+ end
1335
+ end
1336
+
1337
+ def evaluate_transform_block(initial_options, &block)
1338
+ MappingTransformBuilder.new(initial_options).evaluate(&block).to_h
1339
+ end
1340
+
1341
+ def normalize_param_name(name)
1342
+ key = name.to_s.strip
1343
+ raise ArgumentError, "param name is required" if key.empty?
1344
+
1345
+ key.to_sym
1346
+ end
1347
+
1348
+ def normalize_palette(colors)
1349
+ values = colors.flatten.map { |color| color.to_s.strip }.reject(&:empty?)
1350
+ raise ArgumentError, "layer #{@name} palette requires at least one color" if values.empty?
1351
+
1352
+ values
1353
+ end
1354
+
1355
+ def normalize_param_number(value, name)
1356
+ Float(value)
1357
+ rescue ArgumentError, TypeError
1358
+ raise ArgumentError, "param #{name} must be numeric"
1359
+ end
1360
+
1361
+ def validate_param_range!(metadata)
1362
+ return unless metadata.key?(:min) && metadata.key?(:max)
1363
+ return if metadata[:min] <= metadata[:max]
1364
+
1365
+ raise ArgumentError, "param min must be less than or equal to max"
1366
+ end
1367
+
1368
+ def normalize_transform(gain: nil, range: nil, min: nil, max: nil, curve: nil, attack: nil, release: nil, deadzone: nil)
1369
+ range_min, range_max = normalize_range(range, context: "mapping")
1370
+ min = range_min if min.nil?
1371
+ max = range_max if max.nil?
1372
+
1373
+ output = {}
1374
+ output[:deadzone] = normalize_non_negative_float(deadzone, :deadzone) unless deadzone.nil?
1375
+ output[:gain] = normalize_float(gain, :gain) unless gain.nil?
1376
+ output[:min] = normalize_float(min, :min) unless min.nil?
1377
+ output[:max] = normalize_float(max, :max) unless max.nil?
1378
+ output[:curve] = normalize_curve(curve) unless curve.nil?
1379
+ output[:attack] = clamp(normalize_float(attack, :attack), 0.0, 1.0) unless attack.nil?
1380
+ output[:release] = clamp(normalize_float(release, :release), 0.0, 1.0) unless release.nil?
1381
+ output
1382
+ end
1383
+
1384
+ def normalize_range(value, context:)
1385
+ return [nil, nil] if value.nil?
1386
+
1387
+ if value.is_a?(Range)
1388
+ return [value.begin, value.end]
1389
+ end
1390
+
1391
+ if value.is_a?(Array) && value.length == 2
1392
+ return value
1393
+ end
1394
+
1395
+ raise ArgumentError, "#{context} range must be a Range or two-element Array"
1396
+ end
1397
+
1398
+ def normalize_float(value, name)
1399
+ Float(value)
1400
+ rescue ArgumentError, TypeError
1401
+ raise ArgumentError, "mapping #{name} must be numeric"
1402
+ end
1403
+
1404
+ def normalize_non_negative_float(value, name)
1405
+ numeric = normalize_float(value, name)
1406
+ raise ArgumentError, "mapping #{name} must be non-negative" if numeric.negative?
1407
+
1408
+ numeric
1409
+ end
1410
+
1411
+ def normalize_non_negative_param_number(value, name)
1412
+ numeric = Float(value)
1413
+ raise ArgumentError, "#{name} must be non-negative" if numeric.negative?
1414
+
1415
+ numeric
1416
+ rescue ArgumentError, TypeError
1417
+ raise ArgumentError, "#{name} must be numeric"
1418
+ end
1419
+
1420
+ def normalize_curve(value)
1421
+ curve = value.to_sym
1422
+ return curve if %i[linear sqrt square ease_out].include?(curve)
1423
+
1424
+ raise ArgumentError, "unsupported mapping curve: #{value.inspect}"
1425
+ end
1426
+
1427
+ def clamp(value, min, max)
1428
+ [[value, min].max, max].min
1429
+ end
1430
+
1431
+ def mapping_source(kind, **options)
175
1432
  {
176
1433
  kind: kind.to_sym,
177
1434
  **options