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