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.
data/frontend/src/main.js CHANGED
@@ -31,10 +31,24 @@ import {
31
31
  import { applyProjectorMode, resolveProjectorMode } from "./projector-mode.js";
32
32
  import { Engine } from "./renderer/engine.js";
33
33
  import { SHADER_COMPILE_EVENT } from "./renderer/shader-manager.js";
34
+ import {
35
+ customShapeParamControlEntries,
36
+ customShapeParamMessage,
37
+ pruneCustomShapeParamOverrides
38
+ } from "./custom-shape-param-controls.js";
39
+ import {
40
+ mappingTargetOptions,
41
+ mappingTargetSignature
42
+ } from "./mapping-target-selector.js";
34
43
  import {
35
44
  pruneShaderParamOverrides,
36
45
  shaderParamControlEntries
37
46
  } from "./shader-param-controls.js";
47
+ import {
48
+ normalizeShapeEditorPatch,
49
+ pruneShapeEditorOverrides,
50
+ shapeEditorEntries
51
+ } from "./shape-editor-controls.js";
38
52
  import { normalizeRuntimeControlPreset } from "./runtime-control-preset.js";
39
53
  import { SHADER_ERROR_EVENT, formatShaderErrorMessage, formatShaderErrorTitle } from "./shader-error-overlay.js";
40
54
  import {
@@ -91,6 +105,9 @@ const reactivityStatusElement = document.querySelector("#reactivity-status");
91
105
  const midiLearnStatusElement = document.querySelector("#midi-learn-status");
92
106
  const midiLearnButtons = Array.from(document.querySelectorAll("[data-midi-learn-action]"));
93
107
  const shaderParamControlsElement = document.querySelector("#shader-param-controls");
108
+ const shapeEditorControlsElement = document.querySelector("#shape-editor-controls");
109
+ const customShapeParamControlsElement = document.querySelector("#custom-shape-param-controls");
110
+ const mappingTargetSelectorElement = document.querySelector("#mapping-target-selector");
94
111
  const shaderErrorOverlay = document.querySelector("#shader-error-overlay");
95
112
  const shaderErrorTitleElement = document.querySelector("#shader-error-title");
96
113
  const shaderErrorMessageElement = document.querySelector("#shader-error-message");
@@ -119,6 +136,12 @@ let runtimeControlPresetApplied = false;
119
136
  let controlPresetSaveUrl = null;
120
137
  let shaderParamOverrides = {};
121
138
  let shaderParamControlsSignature = "";
139
+ let shapeEditorOverrides = {};
140
+ let shapeEditorControlsSignature = "";
141
+ let customShapeParamOverrides = {};
142
+ let customShapeParamControlsSignature = "";
143
+ let mappingTargetSelectorSignature = "";
144
+ let selectedMappingTarget = "";
122
145
  let midiAccess = null;
123
146
  let pendingMidiLearnAction = null;
124
147
  applyProjectorMode(document.body, projectorMode);
@@ -180,8 +203,14 @@ const client = new WebSocketClient(websocketUrl, {
180
203
  currentSceneName = sceneName;
181
204
  if (sceneChanged) {
182
205
  shaderParamControlsSignature = "";
206
+ shapeEditorControlsSignature = "";
207
+ customShapeParamControlsSignature = "";
208
+ mappingTargetSelectorSignature = "";
183
209
  }
184
210
  updateShaderParamControls(frame?.scene?.layers);
211
+ updateShapeEditorControls(frame?.scene?.layers);
212
+ updateCustomShapeParamControls(frame?.scene?.layers);
213
+ updateMappingTargetSelector(frame?.scene?.layers);
185
214
  const amplitude = Number(frame?.audio?.amplitude || 0).toFixed(4);
186
215
  const bpm = Number(frame?.audio?.bpm || 0);
187
216
  const beat = !!frame?.audio?.beat;
@@ -218,7 +247,13 @@ const client = new WebSocketClient(websocketUrl, {
218
247
  sceneStatusElement.textContent = `Scene: ${currentSceneName}`;
219
248
  renderSceneButtons();
220
249
  shaderParamControlsSignature = "";
250
+ shapeEditorControlsSignature = "";
251
+ customShapeParamControlsSignature = "";
252
+ mappingTargetSelectorSignature = "";
221
253
  updateShaderParamControls(payload?.scene?.layers);
254
+ updateShapeEditorControls(payload?.scene?.layers);
255
+ updateCustomShapeParamControls(payload?.scene?.layers);
256
+ updateMappingTargetSelector(payload?.scene?.layers);
222
257
  }
223
258
  if (Object.prototype.hasOwnProperty.call(payload || {}, "tap_tempo_key")) {
224
259
  updateTapTempoKey(payload?.tap_tempo_key);
@@ -494,6 +529,239 @@ function formatShaderParamValue(value) {
494
529
  return Math.abs(numeric) >= 10 ? numeric.toFixed(1) : numeric.toFixed(2);
495
530
  }
496
531
 
532
+ function updateShapeEditorControls(layers) {
533
+ const entries = shapeEditorEntries(layers, shapeEditorOverrides);
534
+ const signature = shapeEditorControlsSignatureFor(entries);
535
+ if (signature === shapeEditorControlsSignature) {
536
+ return;
537
+ }
538
+
539
+ shapeEditorControlsSignature = signature;
540
+ shapeEditorOverrides = pruneShapeEditorOverrides(shapeEditorOverrides, entries);
541
+ engine.setShapeEditorOverrides(shapeEditorOverrides);
542
+ renderShapeEditorControls(entries);
543
+ }
544
+
545
+ function shapeEditorControlsSignatureFor(entries) {
546
+ return entries.map((entry) => (
547
+ `${entry.key}:${entry.kind}:${JSON.stringify(entry.values)}`
548
+ )).join("|");
549
+ }
550
+
551
+ function renderShapeEditorControls(entries) {
552
+ if (!shapeEditorControlsElement) {
553
+ return;
554
+ }
555
+
556
+ if (!entries.length) {
557
+ shapeEditorControlsElement.hidden = true;
558
+ shapeEditorControlsElement.replaceChildren();
559
+ return;
560
+ }
561
+
562
+ const title = document.createElement("p");
563
+ title.className = "shader-param-controls__title";
564
+ title.textContent = "Shape Editor";
565
+ const controls = entries.map((entry) => createShapeEditorControl(entry));
566
+ shapeEditorControlsElement.replaceChildren(title, ...controls);
567
+ shapeEditorControlsElement.hidden = false;
568
+ }
569
+
570
+ function createShapeEditorControl(entry) {
571
+ const section = document.createElement("details");
572
+ const summary = document.createElement("summary");
573
+ summary.textContent = entry.label;
574
+ section.append(summary);
575
+ section.append(
576
+ createShapeKindControl(entry),
577
+ createShapeNumberControl(entry, "translateX", "Move X", -640, 640, 1),
578
+ createShapeNumberControl(entry, "translateY", "Move Y", -360, 360, 1),
579
+ createShapeNumberControl(entry, "rotate", "Rotate", -180, 180, 1),
580
+ createShapeNumberControl(entry, "scaleX", "Scale X", -4, 4, 0.05),
581
+ createShapeNumberControl(entry, "scaleY", "Scale Y", -4, 4, 0.05),
582
+ createShapeNumberControl(entry, "opacity", "Opacity", 0, 1, 0.05),
583
+ createShapeColorControl(entry, "fill", "Fill"),
584
+ createShapeColorControl(entry, "strokeColor", "Stroke"),
585
+ createShapeNumberControl(entry, "strokeWidth", "Stroke W", 0, 24, 0.5)
586
+ );
587
+ return section;
588
+ }
589
+
590
+ function createShapeKindControl(entry) {
591
+ const label = document.createElement("label");
592
+ const name = document.createElement("span");
593
+ const select = document.createElement("select");
594
+ name.textContent = "Kind";
595
+ ["circle", "line", "rect", "polygon", "polyline", "path", "star"].forEach((kind) => {
596
+ const option = document.createElement("option");
597
+ option.value = kind;
598
+ option.textContent = kind;
599
+ select.append(option);
600
+ });
601
+ select.value = entry.kind;
602
+ select.addEventListener("change", () => {
603
+ writeShapeEditorOverride(entry, { ...entry.values, kind: select.value });
604
+ });
605
+ label.append(name, select);
606
+ return label;
607
+ }
608
+
609
+ function createShapeNumberControl(entry, key, labelText, min, max, step) {
610
+ const label = document.createElement("label");
611
+ const name = document.createElement("span");
612
+ const input = document.createElement("input");
613
+ const value = document.createElement("output");
614
+ name.textContent = labelText;
615
+ input.type = "range";
616
+ input.min = String(min);
617
+ input.max = String(max);
618
+ input.step = String(step);
619
+ input.value = String(entry.values[key]);
620
+ value.value = formatShaderParamValue(entry.values[key]);
621
+ input.addEventListener("input", () => {
622
+ const numeric = Number(input.value);
623
+ writeShapeEditorOverride(entry, { ...entry.values, [key]: numeric });
624
+ value.value = formatShaderParamValue(numeric);
625
+ });
626
+ label.append(name, input, value);
627
+ return label;
628
+ }
629
+
630
+ function createShapeColorControl(entry, key, labelText) {
631
+ const label = document.createElement("label");
632
+ const name = document.createElement("span");
633
+ const input = document.createElement("input");
634
+ const value = document.createElement("output");
635
+ name.textContent = labelText;
636
+ input.type = "color";
637
+ input.value = entry.values[key];
638
+ value.value = entry.values[key];
639
+ input.addEventListener("input", () => {
640
+ writeShapeEditorOverride(entry, { ...entry.values, [key]: input.value, [`${key}Enabled`]: true });
641
+ value.value = input.value;
642
+ });
643
+ label.append(name, input, value);
644
+ return label;
645
+ }
646
+
647
+ function writeShapeEditorOverride(entry, values) {
648
+ shapeEditorOverrides[entry.layerKey] ||= {};
649
+ shapeEditorOverrides[entry.layerKey][entry.shapeIndex] = normalizeShapeEditorPatch(values);
650
+ engine.setShapeEditorOverrides(shapeEditorOverrides);
651
+ }
652
+
653
+ function updateCustomShapeParamControls(layers) {
654
+ const entries = customShapeParamControlEntries(layers, customShapeParamOverrides);
655
+ const signature = customShapeParamControlsSignatureFor(entries);
656
+ if (signature === customShapeParamControlsSignature) {
657
+ return;
658
+ }
659
+
660
+ customShapeParamControlsSignature = signature;
661
+ customShapeParamOverrides = pruneCustomShapeParamOverrides(customShapeParamOverrides, entries);
662
+ renderCustomShapeParamControls(entries);
663
+ }
664
+
665
+ function customShapeParamControlsSignatureFor(entries) {
666
+ return entries.map((entry) => (
667
+ `${entry.key}:${entry.min}:${entry.max}:${entry.step}:${entry.value}`
668
+ )).join("|");
669
+ }
670
+
671
+ function renderCustomShapeParamControls(entries) {
672
+ if (!customShapeParamControlsElement) {
673
+ return;
674
+ }
675
+
676
+ if (!entries.length) {
677
+ customShapeParamControlsElement.hidden = true;
678
+ customShapeParamControlsElement.replaceChildren();
679
+ return;
680
+ }
681
+
682
+ const title = document.createElement("p");
683
+ title.className = "shader-param-controls__title";
684
+ title.textContent = "Custom Shape Params";
685
+ const controls = entries.map((entry) => createCustomShapeParamControl(entry));
686
+ customShapeParamControlsElement.replaceChildren(title, ...controls);
687
+ customShapeParamControlsElement.hidden = false;
688
+ }
689
+
690
+ function createCustomShapeParamControl(entry) {
691
+ const label = document.createElement("label");
692
+ const name = document.createElement("span");
693
+ const input = document.createElement("input");
694
+ const value = document.createElement("output");
695
+ name.textContent = entry.label;
696
+ input.type = "range";
697
+ input.min = String(entry.min);
698
+ input.max = String(entry.max);
699
+ input.step = String(entry.step);
700
+ input.value = String(entry.value);
701
+ value.value = formatShaderParamValue(entry.value);
702
+ input.addEventListener("input", () => {
703
+ const numeric = Number(input.value);
704
+ customShapeParamOverrides[entry.layerKey] ||= {};
705
+ customShapeParamOverrides[entry.layerKey][entry.customShapeIndex] ||= {};
706
+ customShapeParamOverrides[entry.layerKey][entry.customShapeIndex][entry.paramName] = numeric;
707
+ client.send("custom_shape_param", customShapeParamMessage(entry, numeric));
708
+ value.value = formatShaderParamValue(numeric);
709
+ });
710
+ label.append(name, input, value);
711
+ return label;
712
+ }
713
+
714
+ function updateMappingTargetSelector(layers) {
715
+ const options = mappingTargetOptions(layers);
716
+ const signature = mappingTargetSignature(options);
717
+ if (signature === mappingTargetSelectorSignature) {
718
+ return;
719
+ }
720
+
721
+ mappingTargetSelectorSignature = signature;
722
+ renderMappingTargetSelector(options);
723
+ }
724
+
725
+ function renderMappingTargetSelector(options) {
726
+ if (!mappingTargetSelectorElement) {
727
+ return;
728
+ }
729
+
730
+ if (!options.length) {
731
+ selectedMappingTarget = "";
732
+ mappingTargetSelectorElement.hidden = true;
733
+ mappingTargetSelectorElement.replaceChildren();
734
+ return;
735
+ }
736
+
737
+ const title = document.createElement("p");
738
+ title.className = "shader-param-controls__title";
739
+ title.textContent = "Mapping Targets";
740
+ const label = document.createElement("label");
741
+ const name = document.createElement("span");
742
+ const select = document.createElement("select");
743
+ const output = document.createElement("output");
744
+ name.textContent = "Target";
745
+ options.forEach((option) => {
746
+ const item = document.createElement("option");
747
+ item.value = option.target;
748
+ item.textContent = option.label;
749
+ select.append(item);
750
+ });
751
+ if (!options.some((option) => option.target === selectedMappingTarget)) {
752
+ selectedMappingTarget = options[0].target;
753
+ }
754
+ select.value = selectedMappingTarget;
755
+ output.value = selectedMappingTarget;
756
+ select.addEventListener("change", () => {
757
+ selectedMappingTarget = select.value;
758
+ output.value = select.value;
759
+ });
760
+ label.append(name, select, output);
761
+ mappingTargetSelectorElement.replaceChildren(title, label);
762
+ mappingTargetSelectorElement.hidden = false;
763
+ }
764
+
497
765
  function requestSceneSwitch(sceneName) {
498
766
  if (!sceneName || sceneName === currentSceneName) {
499
767
  return;
@@ -0,0 +1,109 @@
1
+ const SHAPE_TARGETS = {
2
+ circle: ["x", "y", "radius", "segments", "count"],
3
+ line: ["x1", "y1", "x2", "y2"],
4
+ rect: ["x", "y", "width", "height", "radius"],
5
+ polygon: [],
6
+ polyline: [],
7
+ path: ["detail", "tolerance", "max_segments"],
8
+ star: ["x", "y", "points", "radius", "inner_radius", "rotation"],
9
+ };
10
+
11
+ const COMMON_SHAPE_TARGETS = [
12
+ "opacity",
13
+ "stroke_width",
14
+ "transform.translate.x",
15
+ "transform.translate.y",
16
+ "transform.rotate",
17
+ "transform.scale",
18
+ "transform.scale.x",
19
+ "transform.scale.y",
20
+ "transform.origin.x",
21
+ "transform.origin.y",
22
+ ];
23
+
24
+ export const mappingTargetOptions = (layers) => {
25
+ const options = [];
26
+ const seen = new Set();
27
+ const layerList = Array.isArray(layers) ? layers : [];
28
+
29
+ layerList.forEach((layer, layerIndex) => {
30
+ const layerName = String(layer?.name || `layer_${layerIndex + 1}`);
31
+ appendLayerParamTargets(options, seen, layer, layerName);
32
+ appendShapeTargets(options, seen, layer, layerName);
33
+ appendCustomShapeTargets(options, seen, layer, layerName);
34
+ });
35
+
36
+ return options;
37
+ };
38
+
39
+ export const mappingTargetSignature = (options) => (
40
+ (Array.isArray(options) ? options : []).map((option) => `${option.layerName}:${option.target}`).join("|")
41
+ );
42
+
43
+ const appendLayerParamTargets = (options, seen, layer, layerName) => {
44
+ const schema = Array.isArray(layer?.param_schema) ? layer.param_schema : [];
45
+ schema.forEach((entry) => {
46
+ const paramName = normalizeName(entry?.name);
47
+ if (!paramName) return;
48
+
49
+ appendOption(options, seen, {
50
+ layerName,
51
+ target: paramName,
52
+ label: `${layerName}.${paramName}`,
53
+ scope: "layer",
54
+ });
55
+ });
56
+ };
57
+
58
+ const appendShapeTargets = (options, seen, layer, layerName) => {
59
+ const shapes = Array.isArray(layer?.params?.shapes) ? layer.params.shapes : [];
60
+ shapes.forEach((shape, shapeIndex) => {
61
+ const kind = String(shape?.kind || shape?.type || "").toLowerCase();
62
+ const shapeName = normalizeName(shape?.id) || `shape_${shapeIndex + 1}`;
63
+ const targets = [...COMMON_SHAPE_TARGETS, ...(SHAPE_TARGETS[kind] || [])];
64
+ targets.forEach((target) => {
65
+ appendOption(options, seen, {
66
+ layerName,
67
+ target: `shapes.${shapeIndex}.${target}`,
68
+ label: `${layerName}.${shapeName}.${target}`,
69
+ scope: "shape",
70
+ });
71
+ });
72
+ });
73
+ };
74
+
75
+ const appendCustomShapeTargets = (options, seen, layer, layerName) => {
76
+ const controls = Array.isArray(layer?.params?.custom_shape_controls) ? layer.params.custom_shape_controls : [];
77
+ controls.forEach((control, fallbackIndex) => {
78
+ const index = finiteIndex(control?.index, fallbackIndex);
79
+ const customShapeName = normalizeName(control?.name) || `custom_shape_${index + 1}`;
80
+ const params = control?.params && typeof control.params === "object" ? Object.keys(control.params) : [];
81
+ const schemaNames = (Array.isArray(control?.param_schema) ? control.param_schema : []).map((entry) => normalizeName(entry?.name)).filter(Boolean);
82
+ [...new Set([...schemaNames, ...params])].sort().forEach((paramName) => {
83
+ appendOption(options, seen, {
84
+ layerName,
85
+ target: `custom_shapes.${index}.params.${paramName}`,
86
+ label: `${layerName}.${customShapeName}.${paramName}`,
87
+ scope: "custom_shape",
88
+ });
89
+ });
90
+ });
91
+ };
92
+
93
+ const appendOption = (options, seen, option) => {
94
+ const key = `${option.layerName}:${option.target}`;
95
+ if (seen.has(key)) return;
96
+
97
+ seen.add(key);
98
+ options.push({ ...option, key });
99
+ };
100
+
101
+ const normalizeName = (value) => {
102
+ const name = String(value || "").trim();
103
+ return name || null;
104
+ };
105
+
106
+ const finiteIndex = (value, fallback) => {
107
+ const numeric = Number(value);
108
+ return Number.isInteger(numeric) && numeric >= 0 ? numeric : fallback;
109
+ };
@@ -1,6 +1,7 @@
1
1
  import { LayerManager } from "./layer-manager.js";
2
2
  import { ShaderManager } from "./shader-manager.js";
3
3
  import { applyShaderParamOverrides } from "../shader-param-controls.js";
4
+ import { applyShapeEditorOverrides } from "../shape-editor-controls.js";
4
5
 
5
6
  export class Engine {
6
7
  constructor(canvas) {
@@ -27,6 +28,7 @@ export class Engine {
27
28
  };
28
29
  this.runtimeGlobals = {};
29
30
  this.shaderParamOverrides = {};
31
+ this.shapeEditorOverrides = {};
30
32
  this.beatHoldUntil = 0;
31
33
  this.frame = {
32
34
  audio: {
@@ -98,6 +100,10 @@ export class Engine {
98
100
  this.shaderParamOverrides = overrides && typeof overrides === "object" ? overrides : {};
99
101
  }
100
102
 
103
+ setShapeEditorOverrides(overrides = {}) {
104
+ this.shapeEditorOverrides = overrides && typeof overrides === "object" ? overrides : {};
105
+ }
106
+
101
107
  start() {
102
108
  this.lastTime = performance.now();
103
109
  requestAnimationFrame((time) => this.render(time));
@@ -156,7 +162,10 @@ export class Engine {
156
162
  });
157
163
  this.visualAudioState = audio;
158
164
  const rawLayers = Array.isArray(this.frame?.scene?.layers) ? this.frame.scene.layers : [];
159
- const layers = applyShaderParamOverrides(rawLayers, this.shaderParamOverrides);
165
+ const layers = applyShapeEditorOverrides(
166
+ applyShaderParamOverrides(rawLayers, this.shaderParamOverrides),
167
+ this.shapeEditorOverrides
168
+ );
160
169
  const amplitude = clamp(Number(audio.amplitude || 0), 0, 1);
161
170
  const rotationSpeed = resolveRotationSpeed(layers, amplitude);
162
171
  this.currentRotationSpeed += (rotationSpeed - this.currentRotationSpeed) * 0.1;
@@ -18,6 +18,7 @@ import {
18
18
  resolveShaderRenderer
19
19
  } from "../plugin-runtime.js";
20
20
  import { SpectrogramRenderer } from "../visuals/spectrogram-renderer.js";
21
+ import { ShapeRenderer } from "../visuals/shape-renderer.js";
21
22
  import { TextRenderer } from "../visuals/text-renderer.js";
22
23
  import { getVJEffectShader } from "../visuals/vj-effects.js";
23
24
  import { FULLSCREEN_VERTEX_SHADER } from "./shader-manager.js";
@@ -205,6 +206,7 @@ export class LayerManager {
205
206
  this.particleSystem = new ParticleSystem(this.gl, this.shaderManager);
206
207
  this.textRenderer = new TextRenderer(this.gl, this.shaderManager);
207
208
  this.imageRenderer = new ImageRenderer(this.gl, this.shaderManager);
209
+ this.shapeRenderer = new ShapeRenderer(this.gl, this.shaderManager);
208
210
  this.spectrogramRenderer = new SpectrogramRenderer(this.gl, this.shaderManager);
209
211
 
210
212
  this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.fullscreenBuffer);
@@ -274,7 +276,7 @@ export class LayerManager {
274
276
  return;
275
277
  }
276
278
  if (isShapeLayer(layer)) {
277
- this.renderShapeLayer(layer, audio, paletteIndex);
279
+ this.renderShapeLayer(layer, audio, time, resolution, paletteIndex);
278
280
  return;
279
281
  }
280
282
  if (isShaderLayer(layer)) {
@@ -556,16 +558,28 @@ export class LayerManager {
556
558
  });
557
559
  }
558
560
 
559
- renderShapeLayer(layer, audio, paletteIndex = 0) {
561
+ renderShapeLayer(layer, audio, time, resolution, paletteIndex = 0) {
560
562
  const params = layer?.params || {};
563
+ const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
564
+ const fallbackColor = [0.85, 0.50 + amplitude * 0.24, 0.95];
565
+ const cssColor = resolveLayerCssColor(params, "#d98cff", paletteIndex);
566
+ const rendered = this.shapeRenderer.render({
567
+ params,
568
+ color: cssColor,
569
+ resolution,
570
+ audio,
571
+ time
572
+ });
573
+ if (rendered) {
574
+ return;
575
+ }
576
+
561
577
  const points = buildShapeLines({ params });
562
578
 
563
579
  if (points.length === 0) {
564
580
  return;
565
581
  }
566
582
 
567
- const amplitude = clamp(Number(audio?.amplitude || 0), 0, 1);
568
- const fallbackColor = [0.85, 0.50 + amplitude * 0.24, 0.95];
569
583
  const color = resolveLayerRgbColor(params, fallbackColor, paletteIndex);
570
584
  this.renderLinePoints(points, color);
571
585
  }
@@ -0,0 +1,157 @@
1
+ const SHAPE_KINDS = ["circle", "line", "rect", "polygon", "polyline", "path", "star"];
2
+
3
+ export const shapeEditorLayerKey = (layer, index) => `${index}:${String(layer?.name || "layer")}`;
4
+
5
+ export const shapeEditorEntries = (layers, overrides = {}) => {
6
+ const entries = [];
7
+ const layerList = Array.isArray(layers) ? layers : [];
8
+
9
+ layerList.forEach((layer, layerIndex) => {
10
+ const shapes = Array.isArray(layer?.params?.shapes) ? layer.params.shapes : [];
11
+ if (!shapes.length) return;
12
+
13
+ const layerKey = shapeEditorLayerKey(layer, layerIndex);
14
+ const layerName = String(layer?.name || `layer_${layerIndex + 1}`);
15
+ shapes.forEach((shape, shapeIndex) => {
16
+ const edited = applyShapePatch(shape, overrides?.[layerKey]?.[shapeIndex]);
17
+ const id = String(edited?.id || `shape_${shapeIndex + 1}`);
18
+ const kind = normalizeKind(edited?.kind || edited?.type);
19
+ entries.push({
20
+ key: `${layerKey}:shapes.${shapeIndex}`,
21
+ layerKey,
22
+ layerName,
23
+ shapeIndex,
24
+ shapeId: id,
25
+ label: `${layerName}.${id}`,
26
+ kind,
27
+ values: shapeEditorValues(edited, kind),
28
+ });
29
+ });
30
+ });
31
+
32
+ return entries;
33
+ };
34
+
35
+ export const applyShapeEditorOverrides = (layers, overrides = {}) => {
36
+ const layerList = Array.isArray(layers) ? layers : [];
37
+
38
+ return layerList.map((layer, layerIndex) => {
39
+ const shapes = Array.isArray(layer?.params?.shapes) ? layer.params.shapes : null;
40
+ if (!shapes) return layer;
41
+
42
+ const layerKey = shapeEditorLayerKey(layer, layerIndex);
43
+ const layerOverrides = overrides?.[layerKey];
44
+ if (!layerOverrides || typeof layerOverrides !== "object") return layer;
45
+
46
+ return {
47
+ ...layer,
48
+ params: {
49
+ ...(layer?.params || {}),
50
+ shapes: shapes.map((shape, shapeIndex) => applyShapePatch(shape, layerOverrides[shapeIndex])),
51
+ },
52
+ };
53
+ });
54
+ };
55
+
56
+ export const pruneShapeEditorOverrides = (overrides = {}, entries = []) => {
57
+ const validKeys = new Set(entries.map((entry) => `${entry.layerKey}:${entry.shapeIndex}`));
58
+ const next = {};
59
+
60
+ Object.entries(overrides || {}).forEach(([layerKey, layerOverrides]) => {
61
+ if (!layerOverrides || typeof layerOverrides !== "object") return;
62
+
63
+ Object.entries(layerOverrides).forEach(([shapeIndex, patch]) => {
64
+ if (!validKeys.has(`${layerKey}:${shapeIndex}`) || !patch || typeof patch !== "object") return;
65
+
66
+ next[layerKey] ||= {};
67
+ next[layerKey][shapeIndex] = patch;
68
+ });
69
+ });
70
+
71
+ return next;
72
+ };
73
+
74
+ export const normalizeShapeEditorPatch = (values = {}) => {
75
+ const kind = normalizeKind(values.kind);
76
+ const output = {
77
+ kind,
78
+ opacity: clamp(finiteNumber(values.opacity, 1), 0, 1),
79
+ stroke_width: Math.max(0, finiteNumber(values.strokeWidth ?? values.stroke_width, 1)),
80
+ transform: {
81
+ translate: {
82
+ x: finiteNumber(values.translateX, 0),
83
+ y: finiteNumber(values.translateY, 0),
84
+ },
85
+ rotate: finiteNumber(values.rotate, 0),
86
+ scale: {
87
+ x: clamp(finiteNumber(values.scaleX, 1), -8, 8),
88
+ y: clamp(finiteNumber(values.scaleY, 1), -8, 8),
89
+ },
90
+ },
91
+ };
92
+ if (values.fillEnabled !== false && validColor(values.fill)) output.fill = values.fill;
93
+ if (values.strokeColorEnabled !== false && validColor(values.strokeColor ?? values.stroke_color)) {
94
+ output.stroke_color = values.strokeColor ?? values.stroke_color;
95
+ }
96
+ return output;
97
+ };
98
+
99
+ const shapeEditorValues = (shape, kind) => {
100
+ const transform = shape?.transform || {};
101
+ const translate = vectorValue(transform.translate ?? shape?.translate, { x: 0, y: 0 });
102
+ const scale = vectorValue(transform.scale ?? shape?.scale, { x: 1, y: 1 });
103
+ return {
104
+ kind,
105
+ translateX: translate.x,
106
+ translateY: translate.y,
107
+ rotate: finiteNumber(transform.rotate ?? shape?.rotate ?? shape?.rotation, 0),
108
+ scaleX: scale.x,
109
+ scaleY: scale.y,
110
+ opacity: clamp(finiteNumber(shape?.opacity, 1), 0, 1),
111
+ fill: validColor(shape?.fill) ? shape.fill : "#000000",
112
+ fillEnabled: validColor(shape?.fill),
113
+ strokeColor: validColor(shape?.stroke_color ?? shape?.strokeColor) ? (shape.stroke_color ?? shape.strokeColor) : "#ffffff",
114
+ strokeColorEnabled: validColor(shape?.stroke_color ?? shape?.strokeColor),
115
+ strokeWidth: Math.max(0, finiteNumber(shape?.stroke_width ?? shape?.strokeWidth ?? shape?.stroke, 1)),
116
+ };
117
+ };
118
+
119
+ const applyShapePatch = (shape, patch) => {
120
+ if (!patch || typeof patch !== "object") return shape;
121
+
122
+ return {
123
+ ...(shape || {}),
124
+ ...patch,
125
+ transform: {
126
+ ...((shape || {}).transform || {}),
127
+ ...(patch.transform || {}),
128
+ },
129
+ };
130
+ };
131
+
132
+ const vectorValue = (value, fallback) => {
133
+ if (Array.isArray(value)) {
134
+ return { x: finiteNumber(value[0], fallback.x), y: finiteNumber(value[1], fallback.y) };
135
+ }
136
+
137
+ if (value && typeof value === "object") {
138
+ return { x: finiteNumber(value.x, fallback.x), y: finiteNumber(value.y, fallback.y) };
139
+ }
140
+
141
+ const numeric = finiteNumber(value, null);
142
+ return numeric === null ? fallback : { x: numeric, y: numeric };
143
+ };
144
+
145
+ const normalizeKind = (value) => {
146
+ const kind = String(value || "circle").trim().toLowerCase();
147
+ return SHAPE_KINDS.includes(kind) ? kind : "circle";
148
+ };
149
+
150
+ const validColor = (value) => /^#[0-9a-f]{6}$/i.test(String(value || ""));
151
+
152
+ const finiteNumber = (value, fallback) => {
153
+ const numeric = Number(value);
154
+ return Number.isFinite(numeric) ? numeric : fallback;
155
+ };
156
+
157
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);