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