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.
@@ -2,15 +2,52 @@
2
2
 
3
3
  require_relative "mapping_transform_builder"
4
4
  require_relative "reaction_builder"
5
+ require_relative "../shape"
5
6
 
6
7
  module Vizcore
7
8
  module DSL
8
9
  # Builder for one render layer in a scene.
9
10
  class LayerBuilder
10
11
  NO_ARGUMENT = Object.new.freeze
12
+ SHAPE_SCHEMA_VERSION = 2
11
13
  MAPPING_SOURCE_KINDS = %i[
12
14
  amplitude frequency_band fft_spectrum onset kick snare hihat beat beat_confidence beat_pulse beat_count bpm
13
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
14
51
 
15
52
  # @param name [Symbol, String] layer identifier
16
53
  # @param styles [Hash] reusable layer parameter styles
@@ -24,6 +61,8 @@ module Vizcore
24
61
  @params = deep_dup(defaults)
25
62
  @param_schema = {}
26
63
  @mappings = []
64
+ @shape_index_by_id = {}
65
+ @shape_group_stack = [{}]
27
66
  end
28
67
 
29
68
  # Evaluate a layer block.
@@ -72,8 +111,8 @@ module Vizcore
72
111
  # @param options [Hash] shape params such as `count`, `radius`, `x`, and `y`
73
112
  # @yield optional block evaluated in the shape context
74
113
  # @return [Hash]
75
- def circle(**options, &block)
76
- build_shape(:circle, options, &block)
114
+ def circle(id = nil, **options, &block)
115
+ build_shape(:circle, shape_options(id, options), &block)
77
116
  end
78
117
 
79
118
  # Declare a 2D line primitive for a shape layer.
@@ -81,8 +120,133 @@ module Vizcore
81
120
  # @param options [Hash] shape params such as `x1`, `y1`, `x2`, and `y2`
82
121
  # @yield optional block evaluated in the shape context
83
122
  # @return [Hash]
84
- def line(**options, &block)
85
- build_shape(:line, options, &block)
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
86
250
  end
87
251
 
88
252
  # Group shape primitives in a block for readability.
@@ -146,6 +310,22 @@ module Vizcore
146
310
  # @param value [String] text fill color
147
311
  # @return [String]
148
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
+
149
329
  @params[:color] = value.to_s
150
330
  end
151
331
 
@@ -160,6 +340,20 @@ module Vizcore
160
340
  return @current_shape
161
341
  end
162
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
+
163
357
  @params[:stroke_width] = normalize_non_negative_param_number(width, :stroke_width) unless width.nil?
164
358
  @params[:stroke_color] = color.to_s unless color.nil?
165
359
  @params
@@ -177,9 +371,193 @@ module Vizcore
177
371
  # @param value [Symbol, String] layer compositing mode
178
372
  # @return [Symbol]
179
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
+
180
390
  @params[:blend] = value.to_sym
181
391
  end
182
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
+
183
561
  # Store an ordered color palette for this layer.
184
562
  #
185
563
  # @param colors [Array<String, Array<String>>] color values such as "#00ffff"
@@ -232,6 +610,7 @@ module Vizcore
232
610
  # @raise [ArgumentError] when the mapping is empty or invalid
233
611
  # @return [void]
234
612
  def map(definition = nil, **options, &block)
613
+ definition, options = normalize_custom_shape_mapping(definition, options) if @custom_shape_target_prefix
235
614
  definition, options = normalize_shape_mapping(definition, options) if @shape_target_prefix
236
615
 
237
616
  if options.key?(:to)
@@ -405,6 +784,16 @@ module Vizcore
405
784
  return args.first
406
785
  end
407
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
+
408
797
  if block.nil? && args.length == 1
409
798
  @params[method_name.to_sym] = args.first
410
799
  return args.first
@@ -414,25 +803,77 @@ module Vizcore
414
803
  end
415
804
 
416
805
  def respond_to_missing?(method_name, include_private = false)
417
- @params.key?(method_name.to_sym) || super
806
+ !!@current_custom_shape || @params.key?(method_name.to_sym) || super
418
807
  end
419
808
 
420
809
  private
421
810
 
422
- def build_shape(kind, options, &block)
811
+ def build_shape(kind, options, schema_version: false, &block)
423
812
  @type ||= :shape
813
+ mark_shape_schema_version! if schema_version
424
814
  shape = normalize_shape(kind, options)
425
815
  @params[:shapes] ||= []
426
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)
427
832
  @params[:shapes] << shape
428
833
 
429
834
  with_shape_context(shape, shape_index) do
430
835
  instance_eval(&block) if block
431
836
  end
837
+ apply_current_shape_group!(shape)
838
+ validate_shape!(shape)
432
839
 
433
840
  shape
434
841
  end
435
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
+
436
877
  def normalize_shape(kind, options)
437
878
  shape = { kind: kind.to_sym }
438
879
  options.each do |key, value|
@@ -441,6 +882,307 @@ module Vizcore
441
882
  shape
442
883
  end
443
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
+
444
1186
  def with_shape_context(shape, shape_index)
445
1187
  previous_shape = @current_shape
446
1188
  previous_prefix = @shape_target_prefix
@@ -452,6 +1194,50 @@ module Vizcore
452
1194
  @shape_target_prefix = previous_prefix
453
1195
  end
454
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
+
455
1241
  def normalize_shape_mapping(definition, options)
456
1242
  if options.key?(:to)
457
1243
  prefixed_options = options.dup
@@ -475,7 +1261,9 @@ module Vizcore
475
1261
  end
476
1262
 
477
1263
  def prefixed_shape_target(target)
478
- :"#{@shape_target_prefix}.#{target}"
1264
+ target_name = target.to_s
1265
+ resolved_target = SHAPE_TARGET_ALIASES.fetch(target_name, target_name)
1266
+ :"#{@shape_target_prefix}.#{resolved_target}"
479
1267
  end
480
1268
 
481
1269
  def resolved_type