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
@@ -0,0 +1,742 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vizcore
4
+ # Mixin and runtime helpers for Ruby-defined custom shape generators.
5
+ module Shape
6
+ SUPPORTED_PRIMITIVES = %i[circle line rect polygon polyline path star].freeze
7
+ STYLE_KEYS = %i[fill stroke stroke_width stroke_color opacity blend line_cap line_join miter_limit dash].freeze
8
+ Definition = Struct.new(:name, :renderer, keyword_init: true)
9
+
10
+ class << self
11
+ def included(base)
12
+ base.extend(ClassMethods)
13
+ end
14
+
15
+ def normalize_primitives(value, shape_name:)
16
+ primitives = case value
17
+ when nil
18
+ []
19
+ when Hash
20
+ [value]
21
+ else
22
+ Array(value)
23
+ end
24
+ primitives.map { |primitive| normalize_primitive(primitive, shape_name: shape_name) }
25
+ end
26
+
27
+ def expand_custom_shape(renderer, params:, shape_id: nil, layer_name: nil, palette: [], audio: {}, time: 0.0, frame: 0, resolution: [1280, 720], globals: {}, shape_name: nil, cache: false)
28
+ definition = shape_definition(renderer)
29
+ renderer = definition.renderer if definition
30
+ shape_name ||= definition&.name || renderer
31
+ cache_key = custom_shape_cache_key(
32
+ renderer: renderer,
33
+ params: params,
34
+ shape_id: shape_id,
35
+ layer_name: layer_name,
36
+ palette: palette,
37
+ resolution: resolution,
38
+ globals: globals,
39
+ shape_name: shape_name
40
+ ) if cache
41
+ return deep_dup(custom_shape_cache[cache_key]) if cache_key && custom_shape_cache.key?(cache_key)
42
+
43
+ context = DrawContext.new(
44
+ params: params,
45
+ param_schema: param_schema_for(renderer),
46
+ shape_id: shape_id,
47
+ layer_name: layer_name,
48
+ palette: palette,
49
+ audio: audio,
50
+ time: time,
51
+ frame: frame,
52
+ resolution: resolution,
53
+ globals: globals
54
+ )
55
+ result = call_renderer(renderer, context, params)
56
+ result = context.shapes if result.nil?
57
+ primitives = normalize_primitives(result, shape_name: shape_name)
58
+ if shape_id && primitives.length != 1
59
+ raise ArgumentError, "custom_shape id can only be assigned when one primitive is produced"
60
+ end
61
+ primitives.first[:id] ||= shape_id.to_sym if shape_id
62
+ custom_shape_cache[cache_key] = deep_dup(primitives) if cache_key
63
+ primitives
64
+ end
65
+
66
+ def param_schema_for(renderer)
67
+ return renderer.shape_param_schema if renderer.respond_to?(:shape_param_schema)
68
+
69
+ {}
70
+ end
71
+
72
+ private
73
+
74
+ def custom_shape_cache
75
+ @custom_shape_cache ||= {}
76
+ end
77
+
78
+ def custom_shape_cache_key(renderer:, params:, shape_id:, layer_name:, palette:, resolution:, globals:, shape_name:)
79
+ [
80
+ renderer_cache_identity(renderer),
81
+ normalize_cache_value(params),
82
+ shape_id&.to_sym,
83
+ layer_name&.to_sym,
84
+ normalize_cache_value(palette),
85
+ normalize_cache_value(resolution),
86
+ normalize_cache_value(globals),
87
+ normalize_cache_value(shape_name)
88
+ ]
89
+ end
90
+
91
+ def renderer_cache_identity(renderer)
92
+ if renderer.is_a?(Module) && renderer.name
93
+ [:module, renderer.name]
94
+ else
95
+ [:object, renderer.object_id]
96
+ end
97
+ end
98
+
99
+ def normalize_cache_value(value)
100
+ case value
101
+ when Hash
102
+ value.map { |key, entry| [key.to_s, normalize_cache_value(entry)] }.sort_by(&:first)
103
+ when Array
104
+ value.map { |entry| normalize_cache_value(entry) }
105
+ when Symbol
106
+ value.to_s
107
+ else
108
+ value
109
+ end
110
+ end
111
+
112
+ def deep_dup(value)
113
+ Vizcore::DeepCopy.copy(value)
114
+ end
115
+
116
+ def shape_definition(renderer)
117
+ return unless renderer.is_a?(Symbol) || renderer.is_a?(String)
118
+ return unless Vizcore.respond_to?(:resolve_shape)
119
+
120
+ Vizcore.resolve_shape(renderer) || raise(ArgumentError, "Unknown custom shape: #{renderer.inspect}. Register it with `Vizcore.register_shape #{renderer.inspect}, ShapeClass`.")
121
+ end
122
+
123
+ def call_renderer(renderer, context, params)
124
+ if renderer.respond_to?(:draw)
125
+ return renderer.draw(context)
126
+ end
127
+
128
+ return renderer.call(context) if renderer.respond_to?(:call) && !renderer.is_a?(Class)
129
+
130
+ instance = instantiate_renderer(renderer, params)
131
+ return instance.draw(context) if instance.respond_to?(:draw)
132
+
133
+ raise ArgumentError, "custom shape renderer must implement draw(context)"
134
+ end
135
+
136
+ def instantiate_renderer(renderer, params)
137
+ renderer.new(**params)
138
+ rescue ArgumentError
139
+ renderer.new
140
+ end
141
+
142
+ def normalize_primitive(primitive, shape_name:)
143
+ values = symbolize_keys(primitive)
144
+ kind = values[:kind]&.to_sym
145
+ unless SUPPORTED_PRIMITIVES.include?(kind)
146
+ raise ArgumentError, "Custom shape `#{shape_name}` returned unsupported primitive kind: #{kind.inspect}"
147
+ end
148
+
149
+ values.merge(kind: kind)
150
+ rescue TypeError
151
+ raise ArgumentError, "Custom shape `#{shape_name}` returned a non-hash primitive"
152
+ end
153
+
154
+ def symbolize_keys(value)
155
+ Hash(value).each_with_object({}) do |(key, entry), output|
156
+ output[key.to_sym] = normalize_value(entry)
157
+ end
158
+ end
159
+
160
+ def normalize_value(value)
161
+ case value
162
+ when Hash
163
+ symbolize_keys(value)
164
+ when Array
165
+ value.map { |entry| normalize_value(entry) }
166
+ else
167
+ value
168
+ end
169
+ end
170
+ end
171
+
172
+ # Class methods added to custom shape classes.
173
+ module ClassMethods
174
+ def param(name, default: nil, range: nil, min: nil, max: nil, step: nil)
175
+ key = name.to_sym
176
+ range_min, range_max = range_values(range)
177
+ metadata = { name: key }
178
+ metadata[:default] = default unless default.nil?
179
+ metadata[:min] = min.nil? ? range_min : min
180
+ metadata[:max] = max.nil? ? range_max : max
181
+ metadata[:step] = step unless step.nil?
182
+ shape_param_schema[key] = metadata.compact
183
+ end
184
+
185
+ def shape_param_schema
186
+ @shape_param_schema ||= {}
187
+ end
188
+
189
+ private
190
+
191
+ def range_values(value)
192
+ return [nil, nil] if value.nil?
193
+ return [value.begin, value.end] if value.is_a?(Range)
194
+ return value if value.is_a?(Array) && value.length == 2
195
+
196
+ raise ArgumentError, "shape param range must be a Range or two-element Array"
197
+ end
198
+ end
199
+
200
+ # Context passed into custom shape draw methods.
201
+ class DrawContext
202
+ def initialize(params:, param_schema: {}, shape_id: nil, layer_name: nil, palette: [], audio: {}, time: 0.0, frame: 0, resolution: [1280, 720], globals: {})
203
+ @param_schema = symbolize_param_schema(param_schema)
204
+ @params = normalize_params(default_params.merge(symbolize_hash(params)))
205
+ @shape_id = shape_id&.to_sym
206
+ @layer_name = layer_name
207
+ @palette = Array(palette)
208
+ @audio = AudioContext.new(audio)
209
+ @time = Float(time || 0)
210
+ @frame = Integer(frame || 0)
211
+ @resolution = Array(resolution)
212
+ @globals = symbolize_hash(globals)
213
+ @builder = PrimitiveBuilder.new
214
+ end
215
+
216
+ attr_reader :params, :shape_id, :layer_name, :palette, :audio, :time, :frame, :resolution, :globals
217
+
218
+ def param(name, default = nil)
219
+ key = name.to_sym
220
+ return @params[key] if @params.key?(key)
221
+
222
+ default
223
+ end
224
+
225
+ def width
226
+ resolution[0]
227
+ end
228
+
229
+ def height
230
+ resolution[1]
231
+ end
232
+
233
+ def draw(&block)
234
+ @builder.draw(&block)
235
+ end
236
+
237
+ def shapes
238
+ @builder.shapes
239
+ end
240
+
241
+ %i[circle line rect polygon polyline path bezier star group].each do |method_name|
242
+ define_method(method_name) do |*args, **options, &block|
243
+ @builder.public_send(method_name, *args, **options, &block)
244
+ end
245
+ end
246
+
247
+ private
248
+
249
+ def default_params
250
+ @param_schema.each_with_object({}) do |(key, metadata), output|
251
+ output[key.to_sym] = metadata[:default] if metadata.key?(:default)
252
+ end
253
+ end
254
+
255
+ def normalize_params(values)
256
+ values.each_with_object({}) do |(key, value), output|
257
+ symbol_key = key.to_sym
258
+ metadata = @param_schema[symbol_key]
259
+ output[symbol_key] = metadata ? normalize_param_value(symbol_key, value, metadata) : value
260
+ end
261
+ end
262
+
263
+ def normalize_param_value(key, value, metadata)
264
+ return value unless numeric_param?(metadata)
265
+
266
+ numeric = numeric_param_value(key, value)
267
+ min = numeric_metadata(metadata[:min])
268
+ max = numeric_metadata(metadata[:max])
269
+ raise ArgumentError, "shape param #{key} must be >= #{min}" if min && numeric < min
270
+ raise ArgumentError, "shape param #{key} must be <= #{max}" if max && numeric > max
271
+
272
+ numeric
273
+ end
274
+
275
+ def numeric_param_value(key, value)
276
+ Float(value)
277
+ rescue ArgumentError, TypeError
278
+ raise ArgumentError, "shape param #{key} must be numeric"
279
+ end
280
+
281
+ def numeric_param?(metadata)
282
+ %i[default min max step].any? do |key|
283
+ metadata.key?(key) && numeric_metadata(metadata[key])
284
+ end
285
+ end
286
+
287
+ def numeric_metadata(value)
288
+ return nil if value.nil?
289
+
290
+ numeric = Float(value)
291
+ numeric if numeric.finite?
292
+ rescue ArgumentError, TypeError
293
+ nil
294
+ end
295
+
296
+ def symbolize_hash(value)
297
+ Hash(value).each_with_object({}) { |(key, entry), output| output[key.to_sym] = entry }
298
+ rescue TypeError
299
+ {}
300
+ end
301
+
302
+ def symbolize_param_schema(value)
303
+ Hash(value).each_with_object({}) do |(key, metadata), output|
304
+ output[key.to_sym] = symbolize_hash(metadata)
305
+ end
306
+ rescue TypeError
307
+ {}
308
+ end
309
+ end
310
+
311
+ # Hash-like audio accessor for custom shape contexts.
312
+ class AudioContext
313
+ def initialize(payload)
314
+ @payload = symbolize_hash(payload)
315
+ end
316
+
317
+ def amplitude
318
+ numeric(@payload[:amplitude])
319
+ end
320
+
321
+ def bass
322
+ numeric(band(:low))
323
+ end
324
+
325
+ def bass_peak
326
+ numeric(band_peak(:low))
327
+ end
328
+
329
+ def mid
330
+ numeric(band(:mid))
331
+ end
332
+
333
+ def mid_peak
334
+ numeric(band_peak(:mid))
335
+ end
336
+
337
+ def high
338
+ numeric(band(:high))
339
+ end
340
+
341
+ def high_peak
342
+ numeric(band_peak(:high))
343
+ end
344
+
345
+ def fft
346
+ Array(@payload[:fft])
347
+ end
348
+
349
+ def beat?
350
+ !!@payload[:beat]
351
+ end
352
+
353
+ def beat_pulse
354
+ numeric(@payload[:beat_pulse])
355
+ end
356
+
357
+ def beat_phase
358
+ numeric(@payload[:beat_phase])
359
+ end
360
+
361
+ def bar_phase
362
+ numeric(@payload[:bar_phase])
363
+ end
364
+
365
+ def bar_count
366
+ Integer(@payload[:bar_count] || 0)
367
+ rescue ArgumentError, TypeError
368
+ 0
369
+ end
370
+
371
+ def phrase_count
372
+ Integer(@payload[:phrase_count] || 0)
373
+ rescue ArgumentError, TypeError
374
+ 0
375
+ end
376
+
377
+ def kick
378
+ numeric(drum(:kick))
379
+ end
380
+
381
+ def snare
382
+ numeric(drum(:snare))
383
+ end
384
+
385
+ def hihat
386
+ numeric(drum(:hihat))
387
+ end
388
+
389
+ def bpm
390
+ numeric(@payload[:bpm])
391
+ end
392
+
393
+ private
394
+
395
+ def band(name)
396
+ bands = symbolize_hash(@payload[:bands])
397
+ bands[name]
398
+ end
399
+
400
+ def band_peak(name)
401
+ peaks = symbolize_hash(@payload[:band_peaks])
402
+ peaks[name]
403
+ end
404
+
405
+ def drum(name)
406
+ drums = symbolize_hash(@payload[:drums])
407
+ drums[name]
408
+ end
409
+
410
+ def numeric(value)
411
+ Float(value || 0)
412
+ rescue ArgumentError, TypeError
413
+ 0.0
414
+ end
415
+
416
+ def symbolize_hash(value)
417
+ Hash(value).each_with_object({}) { |(key, entry), output| output[key.to_sym] = entry }
418
+ rescue TypeError
419
+ {}
420
+ end
421
+ end
422
+
423
+ # Builder used by DrawContext for primitive arrays.
424
+ class PrimitiveBuilder
425
+ NO_ARGUMENT = Object.new.freeze
426
+
427
+ def initialize
428
+ @shapes = []
429
+ @group_stack = [{}]
430
+ end
431
+
432
+ attr_reader :shapes
433
+
434
+ def draw(&block)
435
+ instance_eval(&block) if block
436
+ shapes
437
+ end
438
+
439
+ def circle(id = nil, **options, &block)
440
+ build_shape(:circle, shape_options(id, options), &block)
441
+ end
442
+
443
+ def line(id = nil, **options, &block)
444
+ build_shape(:line, shape_options(id, options), &block)
445
+ end
446
+
447
+ def rect(id = nil, **options, &block)
448
+ build_shape(:rect, shape_options(id, options), &block)
449
+ end
450
+
451
+ def polygon(id = nil, **options, &block)
452
+ build_shape(:polygon, shape_options(id, options), &block)
453
+ end
454
+
455
+ def polyline(id = nil, **options, &block)
456
+ build_shape(:polyline, shape_options(id, options).merge(closed: false), &block)
457
+ end
458
+
459
+ def path(id = nil, **options, &block)
460
+ build_shape(:path, shape_options(id, options).merge(commands: []), &block)
461
+ end
462
+
463
+ def bezier(id = nil, from:, to:, control: nil, c1: nil, c2: nil, **options, &block)
464
+ commands = [["M", *point_values(from)]]
465
+ if control
466
+ commands << ["Q", *point_values(control), *point_values(to)]
467
+ elsif c1 && c2
468
+ commands << ["C", *point_values(c1), *point_values(c2), *point_values(to)]
469
+ else
470
+ raise ArgumentError, "bezier requires either :control or both :c1 and :c2"
471
+ end
472
+ build_shape(:path, shape_options(id, options).merge(commands: commands), &block)
473
+ end
474
+
475
+ def star(id = nil, **options, &block)
476
+ build_shape(:star, shape_options(id, options), &block)
477
+ end
478
+
479
+ def group(_id = nil, **attrs, &block)
480
+ @group_stack << merge_group(current_group, normalize_group(attrs))
481
+ instance_eval(&block) if block
482
+ shapes
483
+ ensure
484
+ @group_stack.pop
485
+ end
486
+
487
+ def fill(value)
488
+ target[:fill] = value.to_s
489
+ end
490
+
491
+ def stroke(value = NO_ARGUMENT, width: nil, color: nil)
492
+ target[:stroke] = non_negative_number(value, :stroke) unless value.equal?(NO_ARGUMENT)
493
+ target[:stroke_width] = non_negative_number(width, :stroke_width) unless width.nil?
494
+ target[:stroke_color] = color.to_s unless color.nil?
495
+ target
496
+ end
497
+
498
+ def blend(value)
499
+ target[:blend] = value.to_sym
500
+ end
501
+
502
+ def opacity(value)
503
+ if @current_shape || !target.key?(:opacity)
504
+ target[:opacity] = number(value, :opacity)
505
+ else
506
+ target[:opacity] = number(target[:opacity], :opacity) * number(value, :opacity)
507
+ end
508
+ end
509
+
510
+ def translate(*args, x: nil, y: nil)
511
+ values = xy_args(args, x: x, y: y, name: :translate)
512
+ target_transform[:translate] = @current_shape ? values : add_xy(target_transform[:translate], values)
513
+ end
514
+
515
+ def rotate(value)
516
+ rotation = number(value, :rotate)
517
+ target_transform[:rotate] = @current_shape ? rotation : number(target_transform[:rotate] || 0, :rotate) + rotation
518
+ end
519
+
520
+ def scale(value = NO_ARGUMENT, x: nil, y: nil)
521
+ values = scale_args(value, x: x, y: y)
522
+ target_transform[:scale] = @current_shape ? values : multiply_scale(target_transform[:scale], values)
523
+ end
524
+
525
+ def origin(*args, x: nil, y: nil)
526
+ target_transform[:origin] = xy_args(args, x: x, y: y, name: :origin)
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 horizontal_to(x)
538
+ append_path_command("H", x)
539
+ end
540
+
541
+ def vertical_to(y)
542
+ append_path_command("V", y)
543
+ end
544
+
545
+ def quad_to(cx, cy, x, y)
546
+ append_path_command("Q", cx, cy, x, y)
547
+ end
548
+
549
+ def cubic_to(c1x, c1y, c2x, c2y, x, y)
550
+ append_path_command("C", c1x, c1y, c2x, c2y, x, 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
+ def method_missing(method_name, *args, &block)
562
+ if @current_shape && block.nil? && args.length == 1
563
+ @current_shape[method_name.to_sym] = args.first
564
+ return args.first
565
+ end
566
+
567
+ super
568
+ end
569
+
570
+ def respond_to_missing?(_method_name, include_private = false)
571
+ !!@current_shape || super
572
+ end
573
+
574
+ private
575
+
576
+ def build_shape(kind, options, &block)
577
+ shape = { kind: kind }.merge(options)
578
+ previous_shape = @current_shape
579
+ @current_shape = shape
580
+ instance_eval(&block) if block
581
+ shape = apply_group(shape)
582
+ @shapes << shape
583
+ shape
584
+ ensure
585
+ @current_shape = previous_shape
586
+ end
587
+
588
+ def shape_options(id, options)
589
+ return options if id.nil?
590
+
591
+ raise ArgumentError, "shape id specified twice" if options.key?(:id)
592
+
593
+ options.merge(id: id.to_sym)
594
+ end
595
+
596
+ def normalize_group(attrs)
597
+ attrs.each_with_object({}) { |(key, value), output| output[key.to_sym] = value }
598
+ end
599
+
600
+ def merge_group(parent, child)
601
+ merged = deep_dup(parent)
602
+ child.each do |key, value|
603
+ if key == :opacity && merged.key?(:opacity)
604
+ merged[:opacity] = number(merged[:opacity], :opacity) * number(value, :opacity)
605
+ else
606
+ merged[key] = value
607
+ end
608
+ end
609
+ merged
610
+ end
611
+
612
+ def apply_group(shape)
613
+ group = current_group
614
+ merged = deep_dup(shape)
615
+ STYLE_KEYS.each do |key|
616
+ next unless group.key?(key)
617
+
618
+ if key == :opacity && merged.key?(:opacity)
619
+ merged[:opacity] = number(group[:opacity], :opacity) * number(merged[:opacity], :opacity)
620
+ else
621
+ merged[key] = group[key] unless merged.key?(key)
622
+ end
623
+ end
624
+ merged[:transform] = compose_transform(group[:transform], merged[:transform]) if group[:transform]
625
+ merged
626
+ end
627
+
628
+ def compose_transform(parent, child)
629
+ return deep_dup(child || {}) unless parent
630
+
631
+ output = deep_dup(parent)
632
+ child = child || {}
633
+ output[:translate] = add_xy(parent[:translate], child[:translate]) if child.key?(:translate)
634
+ output[:origin] = child[:origin] if child.key?(:origin)
635
+ output[:rotate] = number(parent[:rotate] || 0, :rotate) + number(child[:rotate] || 0, :rotate) if child.key?(:rotate)
636
+ output[:scale] = multiply_scale(parent[:scale], child[:scale]) if child.key?(:scale)
637
+ output
638
+ end
639
+
640
+ def add_xy(parent, child)
641
+ parent = parent || {}
642
+ child = child || {}
643
+ {
644
+ x: number(parent[:x] || 0, :x) + number(child[:x] || 0, :x),
645
+ y: number(parent[:y] || 0, :y) + number(child[:y] || 0, :y)
646
+ }
647
+ end
648
+
649
+ def multiply_scale(parent, child)
650
+ parent = scale_pair(parent)
651
+ child = scale_pair(child)
652
+ { x: parent[:x] * child[:x], y: parent[:y] * child[:y] }
653
+ end
654
+
655
+ def scale_pair(value)
656
+ return { x: number(value[:x] || 1, :scale), y: number(value[:y] || 1, :scale) } if value.is_a?(Hash)
657
+
658
+ scale = number(value || 1, :scale)
659
+ { x: scale, y: scale }
660
+ end
661
+
662
+ def target
663
+ @current_shape || current_group
664
+ end
665
+
666
+ def target_transform
667
+ target[:transform] ||= {}
668
+ end
669
+
670
+ def current_group
671
+ @group_stack.last
672
+ end
673
+
674
+ def append_path_command(command, *values)
675
+ raise ArgumentError, "#{command} is only available inside a path shape" unless @current_shape&.fetch(:kind) == :path
676
+
677
+ @current_shape[:commands] ||= []
678
+ @current_shape[:commands] << [command, *values]
679
+ end
680
+
681
+ def xy_args(args, x:, y:, name:)
682
+ if args.length == 2
683
+ return { x: number(args[0], :"#{name}.x"), y: number(args[1], :"#{name}.y") }
684
+ end
685
+
686
+ raise ArgumentError, "#{name} expects x/y keywords or two numeric arguments" if args.any?
687
+
688
+ { x: number(x || 0, :"#{name}.x"), y: number(y || 0, :"#{name}.y") }
689
+ end
690
+
691
+ def scale_args(value, x:, y:)
692
+ if value.equal?(NO_ARGUMENT)
693
+ return { x: number(x || 1, :"scale.x"), y: number(y || 1, :"scale.y") }
694
+ end
695
+
696
+ raise ArgumentError, "scale accepts either a value or x/y keywords" unless x.nil? && y.nil?
697
+
698
+ number(value, :scale)
699
+ end
700
+
701
+ def point_values(value)
702
+ values = Array(value)
703
+ raise ArgumentError, "point must contain x and y" unless values.length == 2
704
+
705
+ values
706
+ end
707
+
708
+ def number(value, name)
709
+ Float(value)
710
+ rescue ArgumentError, TypeError
711
+ raise ArgumentError, "#{name} must be numeric"
712
+ end
713
+
714
+ def non_negative_number(value, name)
715
+ numeric = number(value, name)
716
+ raise ArgumentError, "#{name} must be non-negative" if numeric.negative?
717
+
718
+ numeric
719
+ end
720
+
721
+ def deep_dup(value)
722
+ Vizcore::DeepCopy.copy(value)
723
+ end
724
+ end
725
+ end
726
+
727
+ @shape_registry = {}
728
+
729
+ class << self
730
+ def register_shape(name, klass = nil, &block)
731
+ raise ArgumentError, "register_shape requires a class/module or block" if klass.nil? && block.nil?
732
+ raise ArgumentError, "register_shape accepts either a class/module or block" if klass && block
733
+
734
+ key = name.to_sym
735
+ @shape_registry[key] = Shape::Definition.new(name: key, renderer: klass || block)
736
+ end
737
+
738
+ def resolve_shape(name)
739
+ @shape_registry[name.to_sym]
740
+ end
741
+ end
742
+ end