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