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.
@@ -8,6 +8,9 @@ module Vizcore
8
8
  class SnapshotRenderer
9
9
  DEFAULT_WIDTH = 1280
10
10
  DEFAULT_HEIGHT = 720
11
+ PATH_DEFAULT_MAX_SEGMENTS = 4096
12
+ PATH_HARD_MAX_SEGMENTS = 65_536
13
+ PATH_MAX_RECURSION = 12
11
14
  PALETTE = [
12
15
  [56, 189, 248],
13
16
  [225, 29, 72],
@@ -156,6 +159,7 @@ module Vizcore
156
159
  def render_shape_layer(canvas, layer, audio, color)
157
160
  params = Hash(layer[:params] || layer["params"] || {})
158
161
  shapes = Array(params[:shapes] || params["shapes"])
162
+ context = shape_coordinate_context(params)
159
163
  pulse = clamp(audio[:beat_pulse])
160
164
  alpha = 0.58 + pulse * 0.24
161
165
 
@@ -163,34 +167,522 @@ module Vizcore
163
167
  shape_hash = Hash(shape)
164
168
  case (shape_hash[:kind] || shape_hash["kind"]).to_s
165
169
  when "circle"
166
- render_circle_shape(canvas, shape_hash, color, alpha)
170
+ render_circle_shape(canvas, shape_hash, color, alpha, context)
167
171
  when "line"
168
- render_line_shape(canvas, shape_hash, color, alpha)
172
+ render_line_shape(canvas, shape_hash, color, alpha, context)
173
+ when "rect"
174
+ render_rect_shape(canvas, shape_hash, color, alpha, context)
175
+ when "polygon", "polyline"
176
+ render_polygon_shape(canvas, shape_hash, color, alpha, context)
177
+ when "path"
178
+ render_path_shape(canvas, shape_hash, color, alpha, context)
179
+ when "star"
180
+ render_star_shape(canvas, shape_hash, color, alpha, context)
169
181
  end
170
182
  end
171
183
  rescue ArgumentError, TypeError
172
184
  nil
173
185
  end
174
186
 
175
- def render_circle_shape(canvas, shape, color, alpha)
187
+ def render_circle_shape(canvas, shape, color, alpha, context)
176
188
  count = [[Integer(shape[:count] || shape["count"] || 1), 1].max, 32].min
177
- radius = Float(shape[:radius] || shape["radius"] || 100).abs
178
- x = Float(shape[:x] || shape["x"] || width * 0.5)
179
- y = Float(shape[:y] || shape["y"] || height * 0.5)
180
- x = width * 0.5 if x.abs <= 1.5
181
- y = height * 0.5 if y.abs <= 1.5
189
+ radius = shape_length(shape[:radius] || shape["radius"] || 100, context, :radius)
190
+ center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
182
191
 
183
192
  count.times do |index|
184
- canvas.draw_circle_outline(x, y, radius * ((index + 1).to_f / count), color, alpha: alpha)
193
+ ring_radius = radius * ((index + 1).to_f / count)
194
+ render_polyline_shape(canvas, circle_points(center, ring_radius), shape, color, alpha, context, closed: true)
185
195
  end
186
196
  end
187
197
 
188
- def render_line_shape(canvas, shape, color, alpha)
189
- x1 = Float(shape[:x1] || shape["x1"] || width * 0.2)
190
- y1 = Float(shape[:y1] || shape["y1"] || height * 0.5)
191
- x2 = Float(shape[:x2] || shape["x2"] || width * 0.8)
192
- y2 = Float(shape[:y2] || shape["y2"] || height * 0.5)
193
- canvas.draw_line(x1, y1, x2, y2, color, alpha: alpha)
198
+ def render_line_shape(canvas, shape, color, alpha, context)
199
+ defaults = context[:units] == :legacy || context[:units] == :ndc ? [-0.8, 0, 0.8, 0] : [-100, 0, 100, 0]
200
+ from = shape_point(shape[:x1] || shape["x1"] || defaults[0], shape[:y1] || shape["y1"] || defaults[1], context)
201
+ to = shape_point(shape[:x2] || shape["x2"] || defaults[2], shape[:y2] || shape["y2"] || defaults[3], context)
202
+ draw_shape_segment(canvas, from, to, shape, color, alpha, context)
203
+ end
204
+
205
+ def render_rect_shape(canvas, shape, color, alpha, context)
206
+ center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
207
+ half_width = shape_length(shape[:width] || shape["width"] || 100, context, :x) / 2.0
208
+ half_height = shape_length(shape[:height] || shape["height"] || 100, context, :y) / 2.0
209
+ points = [
210
+ [center[0] - half_width, center[1] - half_height],
211
+ [center[0] + half_width, center[1] - half_height],
212
+ [center[0] + half_width, center[1] + half_height],
213
+ [center[0] - half_width, center[1] + half_height]
214
+ ]
215
+ render_polyline_shape(canvas, points, shape, color, alpha, context, closed: true)
216
+ end
217
+
218
+ def render_polygon_shape(canvas, shape, color, alpha, context)
219
+ points = Array(shape[:points] || shape["points"]).filter_map do |point|
220
+ values = Array(point)
221
+ next if values.length < 2
222
+
223
+ shape_point(values[0], values[1], context)
224
+ end
225
+ closed = (shape[:kind] || shape["kind"]).to_s == "polygon" ? shape.fetch(:closed, shape.fetch("closed", true)) : false
226
+ render_polyline_shape(canvas, points, shape, color, alpha, context, closed: closed)
227
+ end
228
+
229
+ def render_star_shape(canvas, shape, color, alpha, context)
230
+ tips = [[Integer(shape[:points] || shape["points"] || 5), 3].max, 128].min
231
+ center = shape_point(shape[:x] || shape["x"] || 0, shape[:y] || shape["y"] || 0, context)
232
+ radius = shape_length(shape[:radius] || shape["radius"] || 100, context, :radius)
233
+ inner_radius = shape_length(shape[:inner_radius] || shape["inner_radius"] || Float(shape[:radius] || shape["radius"] || 100) * 0.5, context, :radius)
234
+ rotation = Float(shape[:rotation] || shape["rotation"] || -90) * Math::PI / 180.0
235
+ points = (tips * 2).times.map do |index|
236
+ angle = rotation + (index.to_f / (tips * 2)) * Math::PI * 2
237
+ point_radius = index.even? ? radius : inner_radius
238
+ [center[0] + Math.cos(angle) * point_radius, center[1] - Math.sin(angle) * point_radius]
239
+ end
240
+ render_polyline_shape(canvas, points, shape, color, alpha, context, closed: true)
241
+ end
242
+
243
+ def render_path_shape(canvas, shape, color, alpha, context)
244
+ detail = [[Integer(shape[:detail] || shape["detail"] || 32), 4].max, 128].min
245
+ tolerance = path_tolerance(shape)
246
+ segment_budget = { remaining: path_segment_limit(shape) }
247
+ current = nil
248
+ subpath_start = nil
249
+ Array(shape[:commands] || shape["commands"]).each do |entry|
250
+ command, *values = Array(entry)
251
+ values = values.map { |value| Float(value) }
252
+ case command.to_s.upcase
253
+ when "M"
254
+ current = values.first(2)
255
+ subpath_start = current
256
+ when "L"
257
+ next unless current && values.length >= 2
258
+
259
+ current = draw_raw_path_segment(canvas, current, values.first(2), shape, color, alpha, context, segment_budget)
260
+ when "H"
261
+ next unless current && values.length >= 1
262
+
263
+ current = draw_raw_path_segment(canvas, current, [values[0], current[1]], shape, color, alpha, context, segment_budget)
264
+ when "V"
265
+ next unless current && values.length >= 1
266
+
267
+ current = draw_raw_path_segment(canvas, current, [current[0], values[0]], shape, color, alpha, context, segment_budget)
268
+ when "Q"
269
+ next unless current && values.length >= 4
270
+
271
+ current = draw_quadratic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget)
272
+ when "C"
273
+ next unless current && values.length >= 6
274
+
275
+ current = draw_cubic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget)
276
+ when "A"
277
+ next unless current && values.length >= 7
278
+
279
+ current = draw_arc_path(canvas, current, values, detail, shape, color, alpha, context, segment_budget)
280
+ when "Z"
281
+ if current && subpath_start
282
+ current = draw_raw_path_segment(canvas, current, subpath_start, shape, color, alpha, context, segment_budget)
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ def render_polyline_shape(canvas, points, shape, color, alpha, context, closed:)
289
+ return if points.length < 2
290
+
291
+ points.each_cons(2) { |from, to| draw_shape_segment(canvas, from, to, shape, color, alpha, context) }
292
+ draw_shape_segment(canvas, points.last, points.first, shape, color, alpha, context) if closed && points.length > 2
293
+ end
294
+
295
+ def draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget = nil)
296
+ return to if segment_budget && segment_budget[:remaining] <= 0
297
+
298
+ draw_shape_segment(canvas, shape_point(from[0], from[1], context), shape_point(to[0], to[1], context), shape, color, alpha, context)
299
+ segment_budget[:remaining] -= 1 if segment_budget
300
+ to
301
+ end
302
+
303
+ def draw_quadratic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget = nil)
304
+ previous = current
305
+ control = values.first(2)
306
+ endpoint = values.last(2)
307
+ if tolerance
308
+ draw_adaptive_quadratic_path(canvas, current, control, endpoint, tolerance, shape, color, alpha, context, segment_budget)
309
+ return endpoint
310
+ end
311
+
312
+ 1.upto(detail) do |step|
313
+ break if segment_budget && segment_budget[:remaining] <= 0
314
+
315
+ t = step.to_f / detail
316
+ point = [
317
+ quadratic_point(current[0], control[0], endpoint[0], t),
318
+ quadratic_point(current[1], control[1], endpoint[1], t)
319
+ ]
320
+ draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
321
+ previous = point
322
+ end
323
+ endpoint
324
+ end
325
+
326
+ def draw_cubic_path(canvas, current, values, detail, tolerance, shape, color, alpha, context, segment_budget = nil)
327
+ previous = current
328
+ c1 = values[0, 2]
329
+ c2 = values[2, 2]
330
+ endpoint = values[4, 2]
331
+ if tolerance
332
+ draw_adaptive_cubic_path(canvas, current, c1, c2, endpoint, tolerance, shape, color, alpha, context, segment_budget)
333
+ return endpoint
334
+ end
335
+
336
+ 1.upto(detail) do |step|
337
+ break if segment_budget && segment_budget[:remaining] <= 0
338
+
339
+ t = step.to_f / detail
340
+ point = [
341
+ cubic_point(current[0], c1[0], c2[0], endpoint[0], t),
342
+ cubic_point(current[1], c1[1], c2[1], endpoint[1], t)
343
+ ]
344
+ draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
345
+ previous = point
346
+ end
347
+ endpoint
348
+ end
349
+
350
+ def draw_arc_path(canvas, current, values, detail, shape, color, alpha, context, segment_budget = nil)
351
+ endpoint = values[5, 2]
352
+ arc = svg_arc_description(
353
+ from: current,
354
+ to: endpoint,
355
+ rx: values[0],
356
+ ry: values[1],
357
+ x_axis_rotation: values[2],
358
+ large_arc: arc_flag(values[3]),
359
+ sweep: arc_flag(values[4])
360
+ )
361
+ unless arc
362
+ return draw_raw_path_segment(canvas, current, endpoint, shape, color, alpha, context, segment_budget)
363
+ end
364
+
365
+ previous = current
366
+ segments = svg_arc_segment_count(arc, detail)
367
+ 1.upto(segments) do |step|
368
+ break if segment_budget && segment_budget[:remaining] <= 0
369
+
370
+ point = svg_arc_point(arc, step.to_f / segments)
371
+ draw_raw_path_segment(canvas, previous, point, shape, color, alpha, context, segment_budget)
372
+ previous = point
373
+ end
374
+ endpoint
375
+ end
376
+
377
+ def path_segment_limit(shape)
378
+ raw_value = shape[:max_segments] || shape["max_segments"] || PATH_DEFAULT_MAX_SEGMENTS
379
+ [[Integer(raw_value), 1].max, PATH_HARD_MAX_SEGMENTS].min
380
+ rescue ArgumentError, TypeError
381
+ PATH_DEFAULT_MAX_SEGMENTS
382
+ end
383
+
384
+ def path_tolerance(shape)
385
+ return unless shape.key?(:tolerance) || shape.key?("tolerance")
386
+
387
+ value = Float(shape[:tolerance] || shape["tolerance"])
388
+ value if value.finite? && value >= 0
389
+ rescue ArgumentError, TypeError
390
+ nil
391
+ end
392
+
393
+ def draw_adaptive_quadratic_path(canvas, from, control, to, tolerance, shape, color, alpha, context, segment_budget, depth = 0)
394
+ return if segment_budget && segment_budget[:remaining] <= 0
395
+
396
+ if depth >= PATH_MAX_RECURSION || point_line_distance(control, from, to) <= tolerance
397
+ draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget)
398
+ return
399
+ end
400
+
401
+ left_control = midpoint(from, control)
402
+ right_control = midpoint(control, to)
403
+ center = midpoint(left_control, right_control)
404
+ draw_adaptive_quadratic_path(canvas, from, left_control, center, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
405
+ draw_adaptive_quadratic_path(canvas, center, right_control, to, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
406
+ end
407
+
408
+ def draw_adaptive_cubic_path(canvas, from, c1, c2, to, tolerance, shape, color, alpha, context, segment_budget, depth = 0)
409
+ return if segment_budget && segment_budget[:remaining] <= 0
410
+
411
+ flatness = [point_line_distance(c1, from, to), point_line_distance(c2, from, to)].max
412
+ if depth >= PATH_MAX_RECURSION || flatness <= tolerance
413
+ draw_raw_path_segment(canvas, from, to, shape, color, alpha, context, segment_budget)
414
+ return
415
+ end
416
+
417
+ p01 = midpoint(from, c1)
418
+ p12 = midpoint(c1, c2)
419
+ p23 = midpoint(c2, to)
420
+ p012 = midpoint(p01, p12)
421
+ p123 = midpoint(p12, p23)
422
+ center = midpoint(p012, p123)
423
+ draw_adaptive_cubic_path(canvas, from, p01, p012, center, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
424
+ draw_adaptive_cubic_path(canvas, center, p123, p23, to, tolerance, shape, color, alpha, context, segment_budget, depth + 1)
425
+ end
426
+
427
+ def midpoint(from, to)
428
+ [(from[0] + to[0]) * 0.5, (from[1] + to[1]) * 0.5]
429
+ end
430
+
431
+ def point_line_distance(point, from, to)
432
+ dx = to[0] - from[0]
433
+ dy = to[1] - from[1]
434
+ length = Math.sqrt((dx * dx) + (dy * dy))
435
+ return Math.sqrt(((point[0] - from[0])**2) + ((point[1] - from[1])**2)) if length <= 0
436
+
437
+ ((dy * point[0]) - (dx * point[1]) + (to[0] * from[1]) - (to[1] * from[0])).abs / length
438
+ end
439
+
440
+ def draw_shape_segment(canvas, from, to, shape, color, alpha, context)
441
+ from = apply_shape_transform(from, shape, context)
442
+ to = apply_shape_transform(to, shape, context)
443
+ canvas.draw_line(from[0], from[1], to[0], to[1], color, alpha: alpha * shape_opacity(shape))
444
+ end
445
+
446
+ def shape_coordinate_context(params)
447
+ units = (params[:units] || params["units"]).to_s.strip.downcase
448
+ version = Integer(params[:shape_schema_version] || params["shape_schema_version"] || 1)
449
+ { units: (units.empty? ? (version >= 2 ? :logical : :legacy) : units.to_sym) }
450
+ rescue ArgumentError, TypeError
451
+ { units: :legacy }
452
+ end
453
+
454
+ def shape_point(x, y, context)
455
+ [shape_coordinate(x, context, :x), shape_coordinate(y, context, :y)]
456
+ end
457
+
458
+ def shape_coordinate(value, context, axis)
459
+ numeric = Float(value || 0)
460
+ case context[:units]
461
+ when :ndc
462
+ axis == :x ? width * 0.5 + numeric * width * 0.5 : height * 0.5 - numeric * height * 0.5
463
+ when :logical, :center, :center_origin, :px
464
+ axis == :x ? width * 0.5 + numeric : height * 0.5 - numeric
465
+ when :screen, :canvas, :viewport
466
+ numeric
467
+ else
468
+ legacy_shape_coordinate(numeric, axis)
469
+ end
470
+ end
471
+
472
+ def legacy_shape_coordinate(value, axis)
473
+ return axis == :x ? width * 0.5 + value * width * 0.5 : height * 0.5 - value * height * 0.5 if value.abs <= 1.5
474
+
475
+ value
476
+ end
477
+
478
+ def shape_length(value, context, _axis)
479
+ numeric = Float(value || 0).abs
480
+ return numeric * [width, height].min * 0.5 if context[:units] == :ndc || numeric <= 2
481
+
482
+ numeric
483
+ end
484
+
485
+ def circle_points(center, radius)
486
+ segments = 96
487
+ segments.times.map do |index|
488
+ angle = (index.to_f / segments) * Math::PI * 2
489
+ [center[0] + Math.cos(angle) * radius, center[1] + Math.sin(angle) * radius]
490
+ end
491
+ end
492
+
493
+ def apply_shape_transform(point, shape, context)
494
+ transform = shape_transform(shape, context)
495
+ shifted_x = (point[0] - transform[:origin][0]) * transform[:scale][:x]
496
+ shifted_y = (point[1] - transform[:origin][1]) * transform[:scale][:y]
497
+ radians = -transform[:rotate] * Math::PI / 180.0
498
+ cos = Math.cos(radians)
499
+ sin = Math.sin(radians)
500
+ rotated_x = shifted_x * cos - shifted_y * sin
501
+ rotated_y = shifted_x * sin + shifted_y * cos
502
+
503
+ [
504
+ rotated_x + transform[:origin][0] + transform[:translate][:x],
505
+ rotated_y + transform[:origin][1] + transform[:translate][:y]
506
+ ]
507
+ end
508
+
509
+ def shape_transform(shape, context)
510
+ transform = Hash(shape[:transform] || shape["transform"] || {})
511
+ {
512
+ translate: shape_vector_pair(shape_hash_value(transform, :translate) || shape_hash_value(shape, :translate), context),
513
+ origin: shape_origin_pair(shape_hash_value(transform, :origin) || shape_hash_value(shape, :origin), context),
514
+ rotate: Float(shape_hash_value(transform, :rotate) || shape_hash_value(shape, :rotate) || shape_hash_value(shape, :rotation) || 0),
515
+ scale: shape_scale(shape_hash_value(transform, :scale) || shape_hash_value(shape, :scale))
516
+ }
517
+ end
518
+
519
+ def shape_vector_pair(value, context)
520
+ if value.is_a?(Array)
521
+ return { x: shape_vector(value[0], context, :x), y: shape_vector(value[1], context, :y) }
522
+ end
523
+
524
+ values = value.is_a?(Hash) ? value : {}
525
+ { x: shape_vector(shape_hash_value(values, :x) || 0, context, :x), y: shape_vector(shape_hash_value(values, :y) || 0, context, :y) }
526
+ end
527
+
528
+ def shape_origin_pair(value, context)
529
+ if value.is_a?(Array)
530
+ return shape_point(value[0], value[1], context)
531
+ end
532
+
533
+ values = value.is_a?(Hash) ? value : {}
534
+ shape_point(shape_hash_value(values, :x) || 0, shape_hash_value(values, :y) || 0, context)
535
+ end
536
+
537
+ def shape_vector(value, context, axis)
538
+ numeric = Float(value || 0)
539
+ case context[:units]
540
+ when :ndc
541
+ axis == :x ? numeric * width * 0.5 : -numeric * height * 0.5
542
+ else
543
+ axis == :x ? numeric : -numeric
544
+ end
545
+ end
546
+
547
+ def shape_scale(value)
548
+ if value.is_a?(Hash)
549
+ return {
550
+ x: Float(shape_hash_value(value, :x) || 1).clamp(-8.0, 8.0),
551
+ y: Float(shape_hash_value(value, :y) || 1).clamp(-8.0, 8.0)
552
+ }
553
+ end
554
+
555
+ scale = Float(value || 1).clamp(-8.0, 8.0)
556
+ { x: scale, y: scale }
557
+ end
558
+
559
+ def shape_opacity(shape)
560
+ Float(shape[:opacity] || shape["opacity"] || 1).clamp(0.0, 1.0)
561
+ rescue ArgumentError, TypeError
562
+ 1.0
563
+ end
564
+
565
+ def shape_hash_value(hash, key)
566
+ hash[key] || hash[key.to_s]
567
+ end
568
+
569
+ def quadratic_point(from, control, to, t)
570
+ inv = 1.0 - t
571
+ inv * inv * from + 2 * inv * t * control + t * t * to
572
+ end
573
+
574
+ def cubic_point(from, c1, c2, to, t)
575
+ inv = 1.0 - t
576
+ inv * inv * inv * from + 3 * inv * inv * t * c1 + 3 * inv * t * t * c2 + t * t * t * to
577
+ end
578
+
579
+ def svg_arc_description(from:, to:, rx:, ry:, x_axis_rotation:, large_arc:, sweep:)
580
+ return if same_point?(from, to)
581
+
582
+ radius_x = Float(rx || 0).abs
583
+ radius_y = Float(ry || 0).abs
584
+ return if radius_x <= 0 || radius_y <= 0
585
+
586
+ rotation = Float(x_axis_rotation || 0) * Math::PI / 180.0
587
+ cos = Math.cos(rotation)
588
+ sin = Math.sin(rotation)
589
+ dx = (from[0] - to[0]) / 2.0
590
+ dy = (from[1] - to[1]) / 2.0
591
+ x1p = cos * dx + sin * dy
592
+ y1p = -sin * dx + cos * dy
593
+
594
+ scale = (x1p * x1p / (radius_x * radius_x)) + (y1p * y1p / (radius_y * radius_y))
595
+ if scale > 1
596
+ multiplier = Math.sqrt(scale)
597
+ radius_x *= multiplier
598
+ radius_y *= multiplier
599
+ end
600
+
601
+ center = svg_arc_center(
602
+ from: from,
603
+ to: to,
604
+ radius_x: radius_x,
605
+ radius_y: radius_y,
606
+ x1p: x1p,
607
+ y1p: y1p,
608
+ rotation_cos: cos,
609
+ rotation_sin: sin,
610
+ large_arc: large_arc,
611
+ sweep: sweep
612
+ )
613
+ return unless center
614
+
615
+ start_vector = [(x1p - center[:cxp]) / radius_x, (y1p - center[:cyp]) / radius_y]
616
+ end_vector = [(-x1p - center[:cxp]) / radius_x, (-y1p - center[:cyp]) / radius_y]
617
+ start_angle = vector_angle([1.0, 0.0], start_vector)
618
+ delta_angle = vector_angle(start_vector, end_vector)
619
+ delta_angle -= Math::PI * 2 if !sweep && delta_angle.positive?
620
+ delta_angle += Math::PI * 2 if sweep && delta_angle.negative?
621
+
622
+ {
623
+ cx: center[:cx],
624
+ cy: center[:cy],
625
+ rx: radius_x,
626
+ ry: radius_y,
627
+ rotation: rotation,
628
+ start_angle: start_angle,
629
+ delta_angle: delta_angle
630
+ }
631
+ rescue ArgumentError, TypeError
632
+ nil
633
+ end
634
+
635
+ def svg_arc_center(from:, to:, radius_x:, radius_y:, x1p:, y1p:, rotation_cos:, rotation_sin:, large_arc:, sweep:)
636
+ rx2 = radius_x * radius_x
637
+ ry2 = radius_y * radius_y
638
+ x1p2 = x1p * x1p
639
+ y1p2 = y1p * y1p
640
+ denominator = rx2 * y1p2 + ry2 * x1p2
641
+ return if denominator.zero?
642
+
643
+ numerator = [rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2, 0.0].max
644
+ sign = large_arc == sweep ? -1.0 : 1.0
645
+ coefficient = sign * Math.sqrt(numerator / denominator)
646
+ cxp = coefficient * ((radius_x * y1p) / radius_y)
647
+ cyp = coefficient * (-(radius_y * x1p) / radius_x)
648
+ {
649
+ cxp: cxp,
650
+ cyp: cyp,
651
+ cx: rotation_cos * cxp - rotation_sin * cyp + (from[0] + to[0]) / 2.0,
652
+ cy: rotation_sin * cxp + rotation_cos * cyp + (from[1] + to[1]) / 2.0
653
+ }
654
+ end
655
+
656
+ def svg_arc_point(arc, progress)
657
+ angle = arc[:start_angle] + arc[:delta_angle] * progress
658
+ cos_rotation = Math.cos(arc[:rotation])
659
+ sin_rotation = Math.sin(arc[:rotation])
660
+ x = Math.cos(angle) * arc[:rx]
661
+ y = Math.sin(angle) * arc[:ry]
662
+ [
663
+ arc[:cx] + cos_rotation * x - sin_rotation * y,
664
+ arc[:cy] + sin_rotation * x + cos_rotation * y
665
+ ]
666
+ end
667
+
668
+ def svg_arc_segment_count(arc, detail)
669
+ [((arc[:delta_angle].abs / (Math::PI * 2)) * detail).ceil, 1].max
670
+ end
671
+
672
+ def vector_angle(from, to)
673
+ cross = from[0] * to[1] - from[1] * to[0]
674
+ dot = from[0] * to[0] + from[1] * to[1]
675
+ Math.atan2(cross, dot)
676
+ end
677
+
678
+ def same_point?(from, to)
679
+ (from[0] - to[0]).abs < 1e-9 && (from[1] - to[1]).abs < 1e-9
680
+ end
681
+
682
+ def arc_flag(value)
683
+ !Float(value || 0).zero?
684
+ rescue ArgumentError, TypeError
685
+ false
194
686
  end
195
687
 
196
688
  def render_mesh_layer(canvas, layer, audio, color, index)
@@ -67,6 +67,8 @@ module Vizcore
67
67
  @error_reporter = error_reporter || ->(_message) {}
68
68
  @last_error = nil
69
69
  @frame_count = 0
70
+ @custom_shape_param_overrides = {}
71
+ @custom_shape_param_mutex = Mutex.new
70
72
  @transport_playing = initial_transport_playing_state
71
73
  reset_transition_trigger_counters!
72
74
  @tap_tempo = Vizcore::Analysis::TapTempo.new
@@ -210,18 +212,35 @@ module Vizcore
210
212
  true
211
213
  end
212
214
 
215
+ def set_custom_shape_param(layer_name:, custom_shape_index:, param:, value:)
216
+ layer_key = layer_name.to_s
217
+ param_key = param.to_s.strip
218
+ index = Integer(custom_shape_index)
219
+ numeric = finite_float(value)
220
+ return custom_shape_param_overrides_snapshot if layer_key.empty? || param_key.empty? || index.negative? || numeric.nil?
221
+
222
+ @custom_shape_param_mutex.synchronize do
223
+ @custom_shape_param_overrides[layer_key] ||= {}
224
+ @custom_shape_param_overrides[layer_key][index] ||= {}
225
+ @custom_shape_param_overrides[layer_key][index][param_key] = numeric
226
+ deep_dup(@custom_shape_param_overrides)
227
+ end
228
+ rescue ArgumentError, TypeError
229
+ custom_shape_param_overrides_snapshot
230
+ end
231
+
213
232
  # Build one frame payload for transport to frontend.
214
233
  #
215
234
  # @param _elapsed_seconds [Float]
216
235
  # @param samples [Array<Float>, nil]
217
236
  # @raise [Vizcore::FrameBuildError] when frame construction fails
218
237
  # @return [Hash]
219
- def build_frame(_elapsed_seconds, samples = nil)
238
+ def build_frame(elapsed_seconds, samples = nil)
220
239
  started_at_ms = monotonic_ms
221
240
  audio_samples, audio_capture_ms = capture_or_use_samples(samples)
222
241
  analyzed, audio_analysis_ms = measure_ms { @analysis_pipeline.call(audio_samples) }
223
242
  scene = current_scene
224
- layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed) }
243
+ layers, scene_build_ms = measure_ms { build_scene_layers(scene[:layers], analyzed, time: elapsed_seconds, frame: @frame_count) }
225
244
 
226
245
  @scene_serializer.audio_frame(
227
246
  timestamp: Time.now.to_f,
@@ -293,10 +312,40 @@ module Vizcore
293
312
  value.positive? && (value & (value - 1)).zero?
294
313
  end
295
314
 
296
- def build_scene_layers(scene_layers, analyzed)
315
+ def build_scene_layers(scene_layers, analyzed, time: 0.0, frame: 0)
297
316
  return default_scene_layers(analyzed) if scene_layers.empty?
298
317
 
299
- @mapping_resolver.resolve_layers(scene_layers: scene_layers, audio: analyzed)
318
+ @mapping_resolver.resolve_layers(
319
+ scene_layers: scene_layers,
320
+ audio: analyzed,
321
+ time: time,
322
+ frame: frame,
323
+ custom_shape_overrides: custom_shape_param_overrides_snapshot
324
+ )
325
+ end
326
+
327
+ def custom_shape_param_overrides_snapshot
328
+ @custom_shape_param_mutex.synchronize { deep_dup(@custom_shape_param_overrides) }
329
+ end
330
+
331
+ def finite_float(value)
332
+ numeric = Float(value)
333
+ return nil unless numeric.finite?
334
+
335
+ numeric
336
+ rescue ArgumentError, TypeError
337
+ nil
338
+ end
339
+
340
+ def deep_dup(value)
341
+ case value
342
+ when Hash
343
+ value.each_with_object({}) { |(key, entry), output| output[key] = deep_dup(entry) }
344
+ when Array
345
+ value.map { |entry| deep_dup(entry) }
346
+ else
347
+ value
348
+ end
300
349
  end
301
350
 
302
351
  def default_scene_layers(analyzed)
@@ -398,6 +398,8 @@ module Vizcore
398
398
  switch_scene_from_client(target_name, broadcaster)
399
399
  when "tap_tempo"
400
400
  apply_tap_tempo(payload, broadcaster)
401
+ when "custom_shape_param"
402
+ apply_custom_shape_param(payload, broadcaster)
401
403
  end
402
404
  rescue StandardError => e
403
405
  @output.puts(Vizcore::ErrorFormatting.summarize(e, context: "Client control message failed"))
@@ -616,6 +618,25 @@ module Vizcore
616
618
  )
617
619
  end
618
620
 
621
+ def apply_custom_shape_param(payload, broadcaster)
622
+ return unless broadcaster.respond_to?(:set_custom_shape_param)
623
+
624
+ values = Hash(payload)
625
+ overrides = broadcaster.set_custom_shape_param(
626
+ layer_name: values["layer"] || values[:layer] || values["layer_name"] || values[:layer_name],
627
+ custom_shape_index: values["custom_shape_index"] || values[:custom_shape_index] || values["index"] || values[:index],
628
+ param: values["param"] || values[:param],
629
+ value: values["value"] || values[:value]
630
+ )
631
+ WebSocketHandler.broadcast(
632
+ type: "config_update",
633
+ payload: {
634
+ custom_shape_params: overrides,
635
+ source: "ui"
636
+ }
637
+ )
638
+ end
639
+
619
640
  def apply_osc_live_control(control, value)
620
641
  @live_controls[control] = osc_truthy?(value)
621
642
  WebSocketHandler.broadcast(