vizcore 0.1.0 → 1.0.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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +544 -9
  3. data/docs/.nojekyll +0 -0
  4. data/docs/assets/site.css +744 -0
  5. data/docs/assets/vizcore-demo.gif +0 -0
  6. data/docs/assets/vizcore-poster.png +0 -0
  7. data/docs/assets/vj-tunnel.js +159 -0
  8. data/docs/index.html +224 -0
  9. data/examples/README.md +59 -0
  10. data/examples/assets/README.md +19 -0
  11. data/examples/audio_inspector.rb +34 -0
  12. data/examples/club_intro_drop.rb +78 -0
  13. data/examples/kansai_rubykaigi_visual.rb +70 -0
  14. data/examples/live_coding_minimal.rb +22 -0
  15. data/examples/midi_controller_show.rb +78 -0
  16. data/examples/midi_scene_switch.rb +3 -1
  17. data/examples/parser_visualizer.rb +48 -0
  18. data/examples/readme_demo.rb +17 -0
  19. data/examples/rhythm_geometry.rb +34 -0
  20. data/examples/ruby_crystal_show.rb +35 -0
  21. data/examples/shader_playground.rb +18 -0
  22. data/examples/unyo_liquid.rb +59 -0
  23. data/examples/vj_ambient_chill_room.rb +124 -0
  24. data/examples/vj_dnb_jungle.rb +170 -0
  25. data/examples/vj_festival_mainstage.rb +245 -0
  26. data/examples/vj_festival_mainstage.yml +17 -0
  27. data/examples/vj_glitch_industrial.rb +164 -0
  28. data/examples/vj_hiphop_cipher.rb +167 -0
  29. data/examples/vj_jpop_idol_live.rb +210 -0
  30. data/examples/vj_synthwave_retro.rb +173 -0
  31. data/examples/vj_techno_warehouse.rb +195 -0
  32. data/frontend/index.html +468 -2
  33. data/frontend/src/audio-inspector.js +40 -0
  34. data/frontend/src/live-controls.js +131 -0
  35. data/frontend/src/main.js +792 -16
  36. data/frontend/src/midi-learn.js +194 -0
  37. data/frontend/src/performance-monitor.js +183 -0
  38. data/frontend/src/plugin-runtime.js +130 -0
  39. data/frontend/src/projector-mode.js +56 -0
  40. data/frontend/src/renderer/engine.js +148 -3
  41. data/frontend/src/renderer/layer-manager.js +428 -30
  42. data/frontend/src/renderer/shader-manager.js +26 -0
  43. data/frontend/src/runtime-control-preset.js +11 -0
  44. data/frontend/src/shader-error-overlay.js +29 -0
  45. data/frontend/src/shader-param-controls.js +93 -0
  46. data/frontend/src/shaders/builtins.js +380 -2
  47. data/frontend/src/shaders/post-effects.js +52 -0
  48. data/frontend/src/visual-regression.js +67 -0
  49. data/frontend/src/visual-settings-preset.js +103 -0
  50. data/frontend/src/visuals/geometry.js +268 -0
  51. data/frontend/src/visuals/image-renderer.js +291 -0
  52. data/frontend/src/visuals/particle-system.js +56 -10
  53. data/frontend/src/visuals/spectrogram-renderer.js +226 -0
  54. data/frontend/src/visuals/text-renderer.js +112 -11
  55. data/frontend/src/websocket-client.js +12 -1
  56. data/lib/vizcore/analysis/adaptive_normalizer.rb +70 -0
  57. data/lib/vizcore/analysis/beat_detector.rb +4 -2
  58. data/lib/vizcore/analysis/bpm_estimator.rb +8 -0
  59. data/lib/vizcore/analysis/feature_recorder.rb +159 -0
  60. data/lib/vizcore/analysis/feature_replay.rb +84 -0
  61. data/lib/vizcore/analysis/pipeline.rb +235 -11
  62. data/lib/vizcore/analysis/tap_tempo.rb +74 -0
  63. data/lib/vizcore/analysis.rb +4 -0
  64. data/lib/vizcore/audio/dummy_sine_input.rb +1 -1
  65. data/lib/vizcore/audio/fixture_input.rb +65 -0
  66. data/lib/vizcore/audio/input_manager.rb +4 -2
  67. data/lib/vizcore/audio/mic_input.rb +24 -8
  68. data/lib/vizcore/audio/portaudio_ffi.rb +106 -1
  69. data/lib/vizcore/audio.rb +1 -0
  70. data/lib/vizcore/cli/doctor.rb +159 -0
  71. data/lib/vizcore/cli/dsl_reference.rb +99 -0
  72. data/lib/vizcore/cli/layer_docs.rb +46 -0
  73. data/lib/vizcore/cli/scene_diagnostics.rb +23 -0
  74. data/lib/vizcore/cli/scene_inspector.rb +136 -0
  75. data/lib/vizcore/cli/scene_validator.rb +245 -0
  76. data/lib/vizcore/cli/shader_template.rb +68 -0
  77. data/lib/vizcore/cli/shader_uniform_docs.rb +54 -0
  78. data/lib/vizcore/cli.rb +689 -18
  79. data/lib/vizcore/config.rb +103 -2
  80. data/lib/vizcore/control_preset.rb +68 -0
  81. data/lib/vizcore/dsl/engine.rb +277 -5
  82. data/lib/vizcore/dsl/layer_builder.rb +491 -22
  83. data/lib/vizcore/dsl/layer_group_builder.rb +112 -0
  84. data/lib/vizcore/dsl/mapping_resolver.rb +132 -3
  85. data/lib/vizcore/dsl/mapping_transform_builder.rb +71 -0
  86. data/lib/vizcore/dsl/reaction_builder.rb +44 -0
  87. data/lib/vizcore/dsl/scene_builder.rb +61 -5
  88. data/lib/vizcore/dsl/shader_source_resolver.rb +67 -6
  89. data/lib/vizcore/dsl/style_builder.rb +68 -0
  90. data/lib/vizcore/dsl/timeline_builder.rb +138 -0
  91. data/lib/vizcore/dsl/transition_controller.rb +77 -0
  92. data/lib/vizcore/dsl.rb +5 -1
  93. data/lib/vizcore/layer_catalog.rb +273 -0
  94. data/lib/vizcore/project_manifest.rb +152 -0
  95. data/lib/vizcore/renderer/png_writer.rb +57 -0
  96. data/lib/vizcore/renderer/render_sequence.rb +153 -0
  97. data/lib/vizcore/renderer/scene_frame_source.rb +119 -0
  98. data/lib/vizcore/renderer/scene_serializer.rb +36 -3
  99. data/lib/vizcore/renderer/snapshot.rb +38 -0
  100. data/lib/vizcore/renderer/snapshot_renderer.rb +446 -0
  101. data/lib/vizcore/renderer.rb +5 -0
  102. data/lib/vizcore/server/frame_broadcaster.rb +91 -5
  103. data/lib/vizcore/server/gallery_app.rb +155 -0
  104. data/lib/vizcore/server/gallery_page.rb +100 -0
  105. data/lib/vizcore/server/gallery_runner.rb +48 -0
  106. data/lib/vizcore/server/rack_app.rb +203 -4
  107. data/lib/vizcore/server/runner.rb +370 -22
  108. data/lib/vizcore/server/scene_dependency_watcher.rb +79 -0
  109. data/lib/vizcore/server/websocket_handler.rb +60 -10
  110. data/lib/vizcore/server.rb +4 -0
  111. data/lib/vizcore/sync/osc_message.rb +103 -0
  112. data/lib/vizcore/sync/osc_receiver.rb +68 -0
  113. data/lib/vizcore/sync.rb +4 -0
  114. data/lib/vizcore/templates/midi_control_scene.rb +3 -1
  115. data/lib/vizcore/templates/plugin_layer.rb +20 -0
  116. data/lib/vizcore/templates/plugin_readme.md +23 -0
  117. data/lib/vizcore/templates/plugin_renderer.js +43 -0
  118. data/lib/vizcore/templates/plugin_scene.rb +14 -0
  119. data/lib/vizcore/templates/project_readme.md +7 -23
  120. data/lib/vizcore/templates/rubykaigi_scene.rb +30 -0
  121. data/lib/vizcore/version.rb +1 -1
  122. data/lib/vizcore.rb +27 -0
  123. data/scripts/browser_capture.mjs +75 -0
  124. data/sig/vizcore.rbs +362 -0
  125. metadata +83 -3
  126. data/docs/GETTING_STARTED.md +0 -105
@@ -1,16 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "mapping_transform_builder"
4
+ require_relative "reaction_builder"
5
+
3
6
  module Vizcore
4
7
  module DSL
5
8
  # Builder for one render layer in a scene.
6
9
  class LayerBuilder
10
+ NO_ARGUMENT = Object.new.freeze
11
+ MAPPING_SOURCE_KINDS = %i[
12
+ amplitude frequency_band fft_spectrum onset kick snare hihat beat beat_confidence beat_pulse beat_count bpm
13
+ ].freeze
14
+
7
15
  # @param name [Symbol, String] layer identifier
8
- def initialize(name:)
16
+ # @param styles [Hash] reusable layer parameter styles
17
+ # @param defaults [Hash] default params applied before layer-specific values
18
+ def initialize(name:, styles: {}, defaults: {})
9
19
  @name = name.to_sym
20
+ @styles = styles
10
21
  @type = nil
11
22
  @shader = nil
12
23
  @glsl = nil
13
- @params = {}
24
+ @params = deep_dup(defaults)
25
+ @param_schema = {}
14
26
  @mappings = []
15
27
  end
16
28
 
@@ -29,10 +41,16 @@ module Vizcore
29
41
  @type = value.to_sym
30
42
  end
31
43
 
32
- # @param value [Symbol, String] built-in shader key
44
+ # @param value [Symbol, String] built-in shader key or custom GLSL path
45
+ # @param reload [Boolean, nil] accepted for custom shader path compatibility
33
46
  # @return [Symbol]
34
- def shader(value)
35
- @shader = value.to_sym
47
+ def shader(value, reload: nil)
48
+ if shader_path?(value)
49
+ @glsl = value.to_s
50
+ @params[:shader_reload] = !!reload unless reload.nil?
51
+ else
52
+ @shader = value.to_sym
53
+ end
36
54
  @type ||= :shader
37
55
  end
38
56
 
@@ -43,6 +61,49 @@ module Vizcore
43
61
  @type ||= :shader
44
62
  end
45
63
 
64
+ # @param path [String, Pathname] asset file path used by media-like layers
65
+ # @return [String]
66
+ def file(path)
67
+ @params[:file] = path.to_s
68
+ end
69
+
70
+ # Declare a 2D circle/ring primitive for a shape layer.
71
+ #
72
+ # @param options [Hash] shape params such as `count`, `radius`, `x`, and `y`
73
+ # @yield optional block evaluated in the shape context
74
+ # @return [Hash]
75
+ def circle(**options, &block)
76
+ build_shape(:circle, options, &block)
77
+ end
78
+
79
+ # Declare a 2D line primitive for a shape layer.
80
+ #
81
+ # @param options [Hash] shape params such as `x1`, `y1`, `x2`, and `y2`
82
+ # @yield optional block evaluated in the shape context
83
+ # @return [Hash]
84
+ def line(**options, &block)
85
+ build_shape(:line, options, &block)
86
+ end
87
+
88
+ # Group shape primitives in a block for readability.
89
+ #
90
+ # @yield shape declarations
91
+ # @return [Array<Hash>]
92
+ def draw(&block)
93
+ @type ||= :shape
94
+ instance_eval(&block) if block
95
+ @params[:shapes] || []
96
+ end
97
+
98
+ # @param value [Symbol, String] input source for media-like layers
99
+ # @return [Symbol, Hash]
100
+ def source(value, **options)
101
+ source_name = value.to_sym
102
+ return mapping_source(source_name, **options) if options.any? || MAPPING_SOURCE_KINDS.include?(source_name)
103
+
104
+ @params[:source] = source_name
105
+ end
106
+
46
107
  # @param value [Integer] particle count or similar numeric parameter
47
108
  # @return [Integer]
48
109
  def count(value)
@@ -61,52 +122,265 @@ module Vizcore
61
122
  @params[:font_size] = Integer(value)
62
123
  end
63
124
 
125
+ # @param value [Numeric] extra spacing between text glyphs in pixels
126
+ # @return [Float]
127
+ def letter_spacing(value)
128
+ @params[:letter_spacing] = normalize_non_negative_param_number(value, :letter_spacing)
129
+ end
130
+
131
+ # @param value [Symbol, String] text alignment (`left`, `center`, `right`)
132
+ # @return [Symbol]
133
+ def align(value)
134
+ alignment = value.to_sym
135
+ raise ArgumentError, "unsupported text align: #{value.inspect}" unless %i[left center right].include?(alignment)
136
+
137
+ @params[:align] = alignment
138
+ end
139
+
140
+ # @param value [String] text font family
141
+ # @return [String]
142
+ def font(value)
143
+ @params[:font] = value.to_s
144
+ end
145
+
146
+ # @param value [String] text fill color
147
+ # @return [String]
148
+ def fill(value)
149
+ @params[:color] = value.to_s
150
+ end
151
+
152
+ # @param width [Numeric, nil] text stroke width in pixels
153
+ # @param color [String, nil] text stroke color
154
+ # @return [Hash]
155
+ def stroke(value = NO_ARGUMENT, width: nil, color: nil)
156
+ if @current_shape
157
+ @current_shape[:stroke] = normalize_non_negative_param_number(value, :stroke) unless value.equal?(NO_ARGUMENT)
158
+ @current_shape[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
159
+ @current_shape[:stroke_color] = color.to_s unless color.nil?
160
+ return @current_shape
161
+ end
162
+
163
+ @params[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
164
+ @params[:stroke_color] = color.to_s unless color.nil?
165
+ @params
166
+ end
167
+
168
+ # @param color [String, nil] text shadow color
169
+ # @param blur [Numeric, nil] text shadow blur in pixels
170
+ # @return [Hash]
171
+ def shadow(color: nil, blur: nil)
172
+ @params[:shadow_color] = color.to_s unless color.nil?
173
+ @params[:shadow_blur] = normalize_non_negative_param_number(blur, :shadow_blur) unless blur.nil?
174
+ @params
175
+ end
176
+
177
+ # @param value [Symbol, String] layer compositing mode
178
+ # @return [Symbol]
179
+ def blend(value)
180
+ @params[:blend] = value.to_sym
181
+ end
182
+
183
+ # Store an ordered color palette for this layer.
184
+ #
185
+ # @param colors [Array<String, Array<String>>] color values such as "#00ffff"
186
+ # @raise [ArgumentError] when no non-blank colors are supplied
187
+ # @return [Array<String>]
188
+ def palette(*colors)
189
+ @params[:palette] = normalize_palette(colors)
190
+ end
191
+
192
+ # Apply a named style by merging its params into this layer.
193
+ #
194
+ # @param name [Symbol, String] style identifier
195
+ # @raise [ArgumentError] when the style is unknown
196
+ # @return [Hash] applied style params
197
+ def use_style(name)
198
+ style_name = name.to_sym
199
+ style_params = @styles.fetch(style_name) { raise ArgumentError, "unknown style: #{style_name}" }
200
+ @params.merge!(deep_dup(style_params))
201
+ end
202
+
203
+ # Declare numeric metadata for a shader/layer parameter.
204
+ #
205
+ # @param name [Symbol, String] parameter name exposed as `u_param_<name>` for shaders
206
+ # @param default [Numeric, nil] default value stored in layer params
207
+ # @param range [Range, Array, nil] allowed numeric range
208
+ # @param min [Numeric, nil] allowed minimum when `range` is not used
209
+ # @param max [Numeric, nil] allowed maximum when `range` is not used
210
+ # @param step [Numeric, nil] preferred UI step
211
+ # @return [Hash]
212
+ def param(name, default: nil, range: nil, min: nil, max: nil, step: nil)
213
+ key = normalize_param_name(name)
214
+ range_min, range_max = normalize_range(range, context: "param")
215
+ min = range_min if min.nil?
216
+ max = range_max if max.nil?
217
+
218
+ metadata = { name: key }
219
+ metadata[:default] = normalize_param_number(default, :default) unless default.nil?
220
+ metadata[:min] = normalize_param_number(min, :min) unless min.nil?
221
+ metadata[:max] = normalize_param_number(max, :max) unless max.nil?
222
+ metadata[:step] = normalize_param_number(step, :step) unless step.nil?
223
+ validate_param_range!(metadata)
224
+
225
+ @params[key] = metadata[:default] if metadata.key?(:default)
226
+ @param_schema[key] = metadata
227
+ end
228
+
64
229
  # Map analysis source(s) to layer parameter target(s).
65
230
  #
66
- # @param definition [Hash] mapping pairs (`source` => `target`)
231
+ # @param definition [Hash, Symbol, String] mapping pairs or a single source
67
232
  # @raise [ArgumentError] when the mapping is empty or invalid
68
233
  # @return [void]
69
- def map(definition)
70
- mapping = Hash(definition)
234
+ def map(definition = nil, **options, &block)
235
+ definition, options = normalize_shape_mapping(definition, options) if @shape_target_prefix
236
+
237
+ if options.key?(:to)
238
+ transform_options = options.dup
239
+ to = transform_options.delete(:to)
240
+ transform_options = evaluate_transform_block(transform_options, &block) if block
241
+ @mappings << build_mapping(
242
+ source: normalize_source(definition),
243
+ target: to,
244
+ transform: normalize_transform(**transform_options)
245
+ )
246
+ return
247
+ end
248
+
249
+ mapping = definition.nil? ? options : Hash(definition)
71
250
  raise ArgumentError, "map requires at least one mapping pair" if mapping.empty?
251
+ raise ArgumentError, "map block syntax supports one mapping pair" if block && mapping.length != 1
72
252
 
73
253
  mapping.each do |source, target|
74
- @mappings << {
75
- source: normalize_source(source),
76
- target: target.to_sym
77
- }
254
+ target_name, transform = normalize_target(target)
255
+ transform = normalize_transform(**evaluate_transform_block(transform, &block)) if block
256
+ @mappings << build_mapping(source: normalize_source(source), target: target_name, transform: transform)
78
257
  end
79
258
  end
80
259
 
260
+ # High-level mapping DSL for describing audio reactions inside a layer.
261
+ #
262
+ # @param source_value [Hash, Symbol, String] analysis source descriptor
263
+ # @yield Reaction block with `change` and `trigger`
264
+ # @raise [ArgumentError] when no reaction block is provided
265
+ # @return [void]
266
+ def react_to(source_value, &block)
267
+ raise ArgumentError, "react_to requires a block" unless block
268
+
269
+ source_descriptor = normalize_source(source_value)
270
+ reaction = ReactionBuilder.new(
271
+ mapping_factory: lambda do |target, transform_options|
272
+ build_mapping(
273
+ source: source_descriptor,
274
+ target: target,
275
+ transform: normalize_transform(**transform_options)
276
+ )
277
+ end
278
+ )
279
+ @mappings.concat(reaction.evaluate(&block))
280
+ end
281
+
81
282
  # @return [Hash] source descriptor for overall amplitude
82
283
  def amplitude
83
- source(:amplitude)
284
+ mapping_source(:amplitude)
84
285
  end
85
286
 
86
287
  # @param name [Symbol, String] band key (`sub`, `low`, `mid`, `high`)
87
288
  # @return [Hash] source descriptor for a frequency band
88
289
  def frequency_band(name)
89
- source(:frequency_band, band: name.to_sym)
290
+ mapping_source(:frequency_band, band: name.to_sym)
291
+ end
292
+
293
+ # @return [Hash] source descriptor for the sub-bass frequency band
294
+ def sub
295
+ frequency_band(:sub)
296
+ end
297
+
298
+ # @return [Hash] source descriptor for the low/bass frequency band
299
+ def low
300
+ frequency_band(:low)
301
+ end
302
+
303
+ # @return [Hash] source descriptor for the low/bass frequency band
304
+ def bass
305
+ frequency_band(:low)
306
+ end
307
+
308
+ # @return [Hash] source descriptor for the mid frequency band
309
+ def mid
310
+ frequency_band(:mid)
311
+ end
312
+
313
+ # @return [Hash] source descriptor for the high frequency band
314
+ def high
315
+ frequency_band(:high)
316
+ end
317
+
318
+ # @return [Hash] source descriptor for the high/treble frequency band
319
+ def treble
320
+ frequency_band(:high)
90
321
  end
91
322
 
92
323
  # @return [Hash] source descriptor for FFT spectrum array
93
324
  def fft_spectrum
94
- source(:fft_spectrum)
325
+ mapping_source(:fft_spectrum)
326
+ end
327
+
328
+ # @param band [Symbol, String, nil] optional band-specific onset key
329
+ # @return [Hash] source descriptor for positive audio feature changes
330
+ def onset(band = nil)
331
+ options = band.nil? ? {} : { band: band.to_sym }
332
+ mapping_source(:onset, **options)
333
+ end
334
+
335
+ # @return [Hash] source descriptor for low-band percussive confidence
336
+ def kick(value = NO_ARGUMENT)
337
+ return @params[:kick] = value unless value.equal?(NO_ARGUMENT)
338
+
339
+ mapping_source(:kick)
340
+ end
341
+
342
+ # @return [Hash] source descriptor for mid-band percussive confidence
343
+ def snare(value = NO_ARGUMENT)
344
+ return @params[:snare] = value unless value.equal?(NO_ARGUMENT)
345
+
346
+ mapping_source(:snare)
347
+ end
348
+
349
+ # @return [Hash] source descriptor for high-band percussive confidence
350
+ def hihat(value = NO_ARGUMENT)
351
+ return @params[:hihat] = value unless value.equal?(NO_ARGUMENT)
352
+
353
+ mapping_source(:hihat)
95
354
  end
96
355
 
97
356
  # @return [Hash] source descriptor for beat trigger
98
357
  def beat?
99
- source(:beat)
358
+ mapping_source(:beat)
359
+ end
360
+
361
+ # @return [Hash] source descriptor for beat trigger
362
+ def beat
363
+ beat?
364
+ end
365
+
366
+ # @return [Hash] source descriptor for beat detector confidence
367
+ def beat_confidence
368
+ mapping_source(:beat_confidence)
369
+ end
370
+
371
+ # @return [Hash] source descriptor for beat pulse decay value
372
+ def beat_pulse
373
+ mapping_source(:beat_pulse)
100
374
  end
101
375
 
102
376
  # @return [Hash] source descriptor for beat counter
103
377
  def beat_count
104
- source(:beat_count)
378
+ mapping_source(:beat_count)
105
379
  end
106
380
 
107
381
  # @return [Hash] source descriptor for estimated BPM
108
382
  def bpm
109
- source(:bpm)
383
+ mapping_source(:bpm)
110
384
  end
111
385
 
112
386
  # @return [Hash] serialized layer payload
@@ -118,6 +392,7 @@ module Vizcore
118
392
  }
119
393
  layer[:shader] = @shader if @shader
120
394
  layer[:glsl] = @glsl if @glsl
395
+ layer[:param_schema] = @param_schema.values.map(&:dup) unless @param_schema.empty?
121
396
  layer[:mappings] = @mappings.map { |mapping| mapping.dup } unless @mappings.empty?
122
397
  layer
123
398
  end
@@ -125,6 +400,11 @@ module Vizcore
125
400
  # Stores dynamic one-argument setters into `params`.
126
401
  # @api private
127
402
  def method_missing(method_name, *args, &block)
403
+ if @current_shape && block.nil? && args.length == 1
404
+ @current_shape[method_name.to_sym] = args.first
405
+ return args.first
406
+ end
407
+
128
408
  if block.nil? && args.length == 1
129
409
  @params[method_name.to_sym] = args.first
130
410
  return args.first
@@ -139,6 +419,65 @@ module Vizcore
139
419
 
140
420
  private
141
421
 
422
+ def build_shape(kind, options, &block)
423
+ @type ||= :shape
424
+ shape = normalize_shape(kind, options)
425
+ @params[:shapes] ||= []
426
+ shape_index = @params[:shapes].length
427
+ @params[:shapes] << shape
428
+
429
+ with_shape_context(shape, shape_index) do
430
+ instance_eval(&block) if block
431
+ end
432
+
433
+ shape
434
+ end
435
+
436
+ def normalize_shape(kind, options)
437
+ shape = { kind: kind.to_sym }
438
+ options.each do |key, value|
439
+ shape[key.to_sym] = value
440
+ end
441
+ shape
442
+ end
443
+
444
+ def with_shape_context(shape, shape_index)
445
+ previous_shape = @current_shape
446
+ previous_prefix = @shape_target_prefix
447
+ @current_shape = shape
448
+ @shape_target_prefix = "shapes.#{shape_index}"
449
+ yield
450
+ ensure
451
+ @current_shape = previous_shape
452
+ @shape_target_prefix = previous_prefix
453
+ end
454
+
455
+ def normalize_shape_mapping(definition, options)
456
+ if options.key?(:to)
457
+ prefixed_options = options.dup
458
+ prefixed_options[:to] = prefixed_shape_target(prefixed_options[:to])
459
+ return [definition, prefixed_options]
460
+ end
461
+
462
+ mapping = definition.nil? ? options : Hash(definition)
463
+ prefixed_mapping = mapping.each_with_object({}) do |(source, target), output|
464
+ output[source] = prefix_shape_target_value(target)
465
+ end
466
+ [prefixed_mapping, {}]
467
+ end
468
+
469
+ def prefix_shape_target_value(target)
470
+ return prefixed_shape_target(target) unless target.is_a?(Hash)
471
+
472
+ target.merge(to: prefixed_shape_target(target.fetch(:to)))
473
+ rescue KeyError
474
+ target
475
+ end
476
+
477
+ def prefixed_shape_target(target)
478
+ :"#{@shape_target_prefix}.#{target}"
479
+ end
480
+
142
481
  def resolved_type
143
482
  return @type if @type
144
483
  return :shader if @shader || @glsl
@@ -146,17 +485,24 @@ module Vizcore
146
485
  :geometry
147
486
  end
148
487
 
488
+ def shader_path?(value)
489
+ return false if value.is_a?(Symbol)
490
+
491
+ path = value.to_s
492
+ %w[.frag .glsl].include?(File.extname(path).downcase) || path.include?("/")
493
+ end
494
+
149
495
  def normalize_source(source_value)
150
496
  case source_value
151
497
  when Hash
152
498
  kind = source_value[:kind] || source_value["kind"]
153
499
  raise ArgumentError, "mapping source hash must contain :kind" unless kind
154
500
 
155
- source(kind.to_sym, **normalize_source_options(source_value))
501
+ mapping_source(kind.to_sym, **normalize_source_options(source_value))
156
502
  when Symbol
157
- source(source_value)
503
+ mapping_source(source_value)
158
504
  when String
159
- source(source_value.to_sym)
505
+ mapping_source(source_value.to_sym)
160
506
  else
161
507
  raise ArgumentError, "unsupported mapping source: #{source_value.inspect}"
162
508
  end
@@ -171,7 +517,130 @@ module Vizcore
171
517
  end
172
518
  end
173
519
 
174
- def source(kind, **options)
520
+ def normalize_target(target)
521
+ return [target.to_sym, {}] unless target.is_a?(Hash)
522
+
523
+ values = target.each_with_object({}) { |(key, value), output| output[key.to_sym] = value }
524
+ to = values.delete(:to)
525
+ raise ArgumentError, "mapping target hash must contain :to" unless to
526
+
527
+ [to.to_sym, normalize_transform(**values)]
528
+ end
529
+
530
+ def build_mapping(source:, target:, transform: {})
531
+ output = { source: source, target: target.to_sym }
532
+ output[:transform] = transform unless transform.empty?
533
+ output
534
+ end
535
+
536
+ 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
547
+ end
548
+
549
+ def evaluate_transform_block(initial_options, &block)
550
+ MappingTransformBuilder.new(initial_options).evaluate(&block).to_h
551
+ end
552
+
553
+ def normalize_param_name(name)
554
+ key = name.to_s.strip
555
+ raise ArgumentError, "param name is required" if key.empty?
556
+
557
+ key.to_sym
558
+ end
559
+
560
+ def normalize_palette(colors)
561
+ values = colors.flatten.map { |color| color.to_s.strip }.reject(&:empty?)
562
+ raise ArgumentError, "layer #{@name} palette requires at least one color" if values.empty?
563
+
564
+ values
565
+ end
566
+
567
+ def normalize_param_number(value, name)
568
+ Float(value)
569
+ rescue ArgumentError, TypeError
570
+ raise ArgumentError, "param #{name} must be numeric"
571
+ end
572
+
573
+ def validate_param_range!(metadata)
574
+ return unless metadata.key?(:min) && metadata.key?(:max)
575
+ return if metadata[:min] <= metadata[:max]
576
+
577
+ raise ArgumentError, "param min must be less than or equal to max"
578
+ end
579
+
580
+ def normalize_transform(gain: nil, range: nil, min: nil, max: nil, curve: nil, attack: nil, release: nil, deadzone: nil)
581
+ range_min, range_max = normalize_range(range, context: "mapping")
582
+ min = range_min if min.nil?
583
+ max = range_max if max.nil?
584
+
585
+ output = {}
586
+ output[:deadzone] = normalize_non_negative_float(deadzone, :deadzone) unless deadzone.nil?
587
+ output[:gain] = normalize_float(gain, :gain) unless gain.nil?
588
+ output[:min] = normalize_float(min, :min) unless min.nil?
589
+ output[:max] = normalize_float(max, :max) unless max.nil?
590
+ output[:curve] = normalize_curve(curve) unless curve.nil?
591
+ output[:attack] = clamp(normalize_float(attack, :attack), 0.0, 1.0) unless attack.nil?
592
+ output[:release] = clamp(normalize_float(release, :release), 0.0, 1.0) unless release.nil?
593
+ output
594
+ end
595
+
596
+ def normalize_range(value, context:)
597
+ return [nil, nil] if value.nil?
598
+
599
+ if value.is_a?(Range)
600
+ return [value.begin, value.end]
601
+ end
602
+
603
+ if value.is_a?(Array) && value.length == 2
604
+ return value
605
+ end
606
+
607
+ raise ArgumentError, "#{context} range must be a Range or two-element Array"
608
+ end
609
+
610
+ def normalize_float(value, name)
611
+ Float(value)
612
+ rescue ArgumentError, TypeError
613
+ raise ArgumentError, "mapping #{name} must be numeric"
614
+ end
615
+
616
+ def normalize_non_negative_float(value, name)
617
+ numeric = normalize_float(value, name)
618
+ raise ArgumentError, "mapping #{name} must be non-negative" if numeric.negative?
619
+
620
+ numeric
621
+ end
622
+
623
+ def normalize_non_negative_param_number(value, name)
624
+ numeric = Float(value)
625
+ raise ArgumentError, "#{name} must be non-negative" if numeric.negative?
626
+
627
+ numeric
628
+ rescue ArgumentError, TypeError
629
+ raise ArgumentError, "#{name} must be numeric"
630
+ end
631
+
632
+ def normalize_curve(value)
633
+ curve = value.to_sym
634
+ return curve if %i[linear sqrt square ease_out].include?(curve)
635
+
636
+ raise ArgumentError, "unsupported mapping curve: #{value.inspect}"
637
+ end
638
+
639
+ def clamp(value, min, max)
640
+ [[value, min].max, max].min
641
+ end
642
+
643
+ def mapping_source(kind, **options)
175
644
  {
176
645
  kind: kind.to_sym,
177
646
  **options