@stevejtrettel/shader-sandbox 0.1.3 → 0.1.4

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.
Files changed (113) hide show
  1. package/README.md +220 -23
  2. package/bin/cli.js +106 -14
  3. package/dist-lib/app/App.d.ts +143 -15
  4. package/dist-lib/app/App.d.ts.map +1 -1
  5. package/dist-lib/app/App.js +1343 -108
  6. package/dist-lib/app/app.css +349 -24
  7. package/dist-lib/app/types.d.ts +48 -5
  8. package/dist-lib/app/types.d.ts.map +1 -1
  9. package/dist-lib/editor/EditorPanel.d.ts +2 -2
  10. package/dist-lib/editor/EditorPanel.d.ts.map +1 -1
  11. package/dist-lib/editor/EditorPanel.js +1 -1
  12. package/dist-lib/editor/editor-panel.css +55 -32
  13. package/dist-lib/editor/prism-editor.css +16 -16
  14. package/dist-lib/embed.js +1 -1
  15. package/dist-lib/engine/{ShadertoyEngine.d.ts → ShaderEngine.d.ts} +134 -10
  16. package/dist-lib/engine/ShaderEngine.d.ts.map +1 -0
  17. package/dist-lib/engine/ShaderEngine.js +1523 -0
  18. package/dist-lib/engine/glHelpers.d.ts +24 -0
  19. package/dist-lib/engine/glHelpers.d.ts.map +1 -1
  20. package/dist-lib/engine/glHelpers.js +88 -0
  21. package/dist-lib/engine/std140.d.ts +47 -0
  22. package/dist-lib/engine/std140.d.ts.map +1 -0
  23. package/dist-lib/engine/std140.js +119 -0
  24. package/dist-lib/engine/types.d.ts +55 -5
  25. package/dist-lib/engine/types.d.ts.map +1 -1
  26. package/dist-lib/engine/types.js +1 -1
  27. package/dist-lib/index.d.ts +4 -3
  28. package/dist-lib/index.d.ts.map +1 -1
  29. package/dist-lib/index.js +2 -1
  30. package/dist-lib/layouts/SplitLayout.d.ts +2 -1
  31. package/dist-lib/layouts/SplitLayout.d.ts.map +1 -1
  32. package/dist-lib/layouts/SplitLayout.js +3 -0
  33. package/dist-lib/layouts/TabbedLayout.d.ts.map +1 -1
  34. package/dist-lib/layouts/UILayout.d.ts +55 -0
  35. package/dist-lib/layouts/UILayout.d.ts.map +1 -0
  36. package/dist-lib/layouts/UILayout.js +147 -0
  37. package/dist-lib/layouts/default.css +2 -2
  38. package/dist-lib/layouts/index.d.ts +11 -1
  39. package/dist-lib/layouts/index.d.ts.map +1 -1
  40. package/dist-lib/layouts/index.js +17 -1
  41. package/dist-lib/layouts/split.css +33 -31
  42. package/dist-lib/layouts/tabbed.css +127 -74
  43. package/dist-lib/layouts/types.d.ts +14 -3
  44. package/dist-lib/layouts/types.d.ts.map +1 -1
  45. package/dist-lib/main.js +33 -0
  46. package/dist-lib/project/configHelpers.d.ts +45 -0
  47. package/dist-lib/project/configHelpers.d.ts.map +1 -0
  48. package/dist-lib/project/configHelpers.js +196 -0
  49. package/dist-lib/project/generatedLoader.d.ts +2 -2
  50. package/dist-lib/project/generatedLoader.d.ts.map +1 -1
  51. package/dist-lib/project/generatedLoader.js +23 -5
  52. package/dist-lib/project/loadProject.d.ts +6 -6
  53. package/dist-lib/project/loadProject.d.ts.map +1 -1
  54. package/dist-lib/project/loadProject.js +396 -144
  55. package/dist-lib/project/loaderHelper.d.ts +4 -4
  56. package/dist-lib/project/loaderHelper.d.ts.map +1 -1
  57. package/dist-lib/project/loaderHelper.js +278 -116
  58. package/dist-lib/project/types.d.ts +292 -13
  59. package/dist-lib/project/types.d.ts.map +1 -1
  60. package/dist-lib/project/types.js +13 -1
  61. package/dist-lib/styles/base.css +5 -1
  62. package/dist-lib/uniforms/UniformControls.d.ts +60 -0
  63. package/dist-lib/uniforms/UniformControls.d.ts.map +1 -0
  64. package/dist-lib/uniforms/UniformControls.js +518 -0
  65. package/dist-lib/uniforms/UniformStore.d.ts +74 -0
  66. package/dist-lib/uniforms/UniformStore.d.ts.map +1 -0
  67. package/dist-lib/uniforms/UniformStore.js +145 -0
  68. package/dist-lib/uniforms/UniformsPanel.d.ts +53 -0
  69. package/dist-lib/uniforms/UniformsPanel.d.ts.map +1 -0
  70. package/dist-lib/uniforms/UniformsPanel.js +124 -0
  71. package/dist-lib/uniforms/index.d.ts +11 -0
  72. package/dist-lib/uniforms/index.d.ts.map +1 -0
  73. package/dist-lib/uniforms/index.js +8 -0
  74. package/package.json +1 -1
  75. package/src/app/App.ts +1469 -126
  76. package/src/app/app.css +349 -24
  77. package/src/app/types.ts +53 -5
  78. package/src/editor/EditorPanel.ts +5 -5
  79. package/src/editor/editor-panel.css +55 -32
  80. package/src/editor/prism-editor.css +16 -16
  81. package/src/embed.ts +1 -1
  82. package/src/engine/ShaderEngine.ts +1934 -0
  83. package/src/engine/glHelpers.ts +117 -0
  84. package/src/engine/std140.ts +136 -0
  85. package/src/engine/types.ts +69 -5
  86. package/src/index.ts +4 -3
  87. package/src/layouts/SplitLayout.ts +8 -3
  88. package/src/layouts/TabbedLayout.ts +3 -3
  89. package/src/layouts/UILayout.ts +185 -0
  90. package/src/layouts/default.css +2 -2
  91. package/src/layouts/index.ts +20 -1
  92. package/src/layouts/split.css +33 -31
  93. package/src/layouts/tabbed.css +127 -74
  94. package/src/layouts/types.ts +19 -3
  95. package/src/layouts/ui.css +289 -0
  96. package/src/main.ts +39 -1
  97. package/src/project/configHelpers.ts +225 -0
  98. package/src/project/generatedLoader.ts +27 -6
  99. package/src/project/loadProject.ts +459 -173
  100. package/src/project/loaderHelper.ts +377 -130
  101. package/src/project/types.ts +360 -14
  102. package/src/styles/base.css +5 -1
  103. package/src/styles/theme.css +292 -0
  104. package/src/uniforms/UniformControls.ts +660 -0
  105. package/src/uniforms/UniformStore.ts +166 -0
  106. package/src/uniforms/UniformsPanel.ts +163 -0
  107. package/src/uniforms/index.ts +13 -0
  108. package/src/uniforms/uniform-controls.css +342 -0
  109. package/src/uniforms/uniforms-panel.css +277 -0
  110. package/templates/shaders/example-buffer/config.json +1 -0
  111. package/dist-lib/engine/ShadertoyEngine.d.ts.map +0 -1
  112. package/dist-lib/engine/ShadertoyEngine.js +0 -704
  113. package/src/engine/ShadertoyEngine.ts +0 -929
@@ -0,0 +1,660 @@
1
+ /**
2
+ * Uniform Controls Component
3
+ *
4
+ * Renders UI controls for custom uniforms defined in config.json.
5
+ * Supports: float, int, bool, vec2 (XY pad), vec3 (color picker or sliders), vec4 (sliders)
6
+ */
7
+
8
+ import './uniform-controls.css';
9
+
10
+ import {
11
+ UniformDefinitions,
12
+ UniformDefinition,
13
+ UniformValue,
14
+ UniformValues,
15
+ FloatUniformDefinition,
16
+ IntUniformDefinition,
17
+ BoolUniformDefinition,
18
+ Vec2UniformDefinition,
19
+ Vec3UniformDefinition,
20
+ Vec4UniformDefinition,
21
+ isArrayUniform,
22
+ } from '../project/types';
23
+
24
+ export interface UniformControlsOptions {
25
+ /** Container element to mount controls into */
26
+ container: HTMLElement;
27
+ /** Uniform definitions from project config */
28
+ uniforms: UniformDefinitions;
29
+ /** Callback when a uniform value changes */
30
+ onChange: (name: string, value: UniformValue) => void;
31
+ /** Initial values (optional, defaults to definition values) */
32
+ initialValues?: UniformValues;
33
+ }
34
+
35
+ interface SliderRowResult {
36
+ element: HTMLElement;
37
+ update: (value: number) => void;
38
+ }
39
+
40
+ export class UniformControls {
41
+ private container: HTMLElement;
42
+ private uniforms: UniformDefinitions;
43
+ private onChange: (name: string, value: UniformValue) => void;
44
+ private values: UniformValues = {};
45
+ private updaters: Map<string, (value: UniformValue) => void> = new Map();
46
+
47
+ // Track document-level event listeners for cleanup
48
+ private documentListeners: Array<{ type: string; handler: EventListener }> = [];
49
+
50
+ constructor(opts: UniformControlsOptions) {
51
+ this.container = opts.container;
52
+ this.uniforms = opts.uniforms;
53
+ this.onChange = opts.onChange;
54
+
55
+ // Initialize values
56
+ for (const [name, def] of Object.entries(this.uniforms)) {
57
+ if (isArrayUniform(def) || def.hidden) continue;
58
+ this.values[name] = opts.initialValues?.[name] ?? def.value;
59
+ }
60
+
61
+ this.render();
62
+ }
63
+
64
+ /**
65
+ * Render all uniform controls.
66
+ */
67
+ private render(): void {
68
+ this.container.innerHTML = '';
69
+ this.container.className = 'uniform-controls';
70
+
71
+ const uniformEntries = Object.entries(this.uniforms);
72
+
73
+ if (uniformEntries.length === 0) {
74
+ const emptyMsg = document.createElement('div');
75
+ emptyMsg.className = 'uniform-controls-empty';
76
+ emptyMsg.textContent = 'No uniforms defined';
77
+ this.container.appendChild(emptyMsg);
78
+ return;
79
+ }
80
+
81
+ // Header with reset button
82
+ const header = document.createElement('div');
83
+ header.className = 'uniform-controls-header';
84
+
85
+ const resetButton = document.createElement('button');
86
+ resetButton.className = 'uniform-controls-reset';
87
+ resetButton.textContent = 'Reset';
88
+ resetButton.title = 'Reset all uniforms to defaults';
89
+ resetButton.addEventListener('click', () => this.resetToDefaults());
90
+
91
+ header.appendChild(resetButton);
92
+ this.container.appendChild(header);
93
+
94
+ // Control list
95
+ const controlList = document.createElement('div');
96
+ controlList.className = 'uniform-controls-list';
97
+
98
+ for (const [name, def] of uniformEntries) {
99
+ if (isArrayUniform(def) || def.hidden) continue;
100
+ const result = this.createControl(name, def);
101
+ if (result) {
102
+ this.updaters.set(name, result.update);
103
+ controlList.appendChild(result.element);
104
+ }
105
+ }
106
+
107
+ this.container.appendChild(controlList);
108
+ }
109
+
110
+ /**
111
+ * Create a control element for a uniform.
112
+ */
113
+ private createControl(name: string, def: UniformDefinition): { element: HTMLElement; update: (v: UniformValue) => void } | null {
114
+ if (isArrayUniform(def) || def.hidden) return null;
115
+ switch (def.type) {
116
+ case 'float':
117
+ return this.createFloatSlider(name, def);
118
+ case 'int':
119
+ return this.createIntSlider(name, def);
120
+ case 'bool':
121
+ return this.createBoolToggle(name, def);
122
+ case 'vec2':
123
+ return this.createVec2Pad(name, def);
124
+ case 'vec3':
125
+ return def.color ? this.createColorPicker(name, def) : this.createVecSliders(name, def, 3);
126
+ case 'vec4':
127
+ return def.color ? this.createColorPicker4(name, def) : this.createVecSliders(name, def, 4);
128
+ default:
129
+ console.warn(`Uniform '${name}': unknown type '${(def as any).type}'`);
130
+ return null;
131
+ }
132
+ }
133
+
134
+ // ===========================================================================
135
+ // Shared Slider Row Helper
136
+ // ===========================================================================
137
+
138
+ private createSliderRow(opts: {
139
+ label: string;
140
+ min: number;
141
+ max: number;
142
+ step: number;
143
+ value: number;
144
+ format: (v: number) => string;
145
+ onInput: (v: number) => void;
146
+ }): SliderRowResult {
147
+ const wrapper = document.createElement('div');
148
+ wrapper.className = 'uniform-control-label-row';
149
+
150
+ const labelEl = document.createElement('label');
151
+ labelEl.className = 'uniform-control-label';
152
+ labelEl.textContent = opts.label;
153
+
154
+ const valueDisplay = document.createElement('span');
155
+ valueDisplay.className = 'uniform-control-value';
156
+ valueDisplay.textContent = opts.format(opts.value);
157
+
158
+ wrapper.appendChild(labelEl);
159
+ wrapper.appendChild(valueDisplay);
160
+
161
+ const slider = document.createElement('input');
162
+ slider.type = 'range';
163
+ slider.className = 'uniform-control-slider';
164
+ slider.min = String(opts.min);
165
+ slider.max = String(opts.max);
166
+ slider.step = String(opts.step);
167
+ slider.value = String(opts.value);
168
+
169
+ slider.addEventListener('input', () => {
170
+ const v = parseFloat(slider.value);
171
+ opts.onInput(v);
172
+ valueDisplay.textContent = opts.format(v);
173
+ });
174
+
175
+ const container = document.createElement('div');
176
+ container.appendChild(wrapper);
177
+ container.appendChild(slider);
178
+
179
+ const update = (v: number) => {
180
+ slider.value = String(v);
181
+ valueDisplay.textContent = opts.format(v);
182
+ };
183
+
184
+ return { element: container, update };
185
+ }
186
+
187
+ // ===========================================================================
188
+ // Float Slider
189
+ // ===========================================================================
190
+
191
+ private createFloatSlider(name: string, def: FloatUniformDefinition): { element: HTMLElement; update: (v: UniformValue) => void } {
192
+ const step = def.step ?? 0.01;
193
+ const { element, update: sliderUpdate } = this.createSliderRow({
194
+ label: def.label ?? name,
195
+ min: def.min ?? 0,
196
+ max: def.max ?? 1,
197
+ step,
198
+ value: this.values[name] as number,
199
+ format: (v) => this.formatNumber(v, step),
200
+ onInput: (v) => {
201
+ this.values[name] = v;
202
+ this.onChange(name, v);
203
+ },
204
+ });
205
+
206
+ const wrapper = document.createElement('div');
207
+ wrapper.className = 'uniform-control uniform-control-float';
208
+ wrapper.appendChild(element);
209
+
210
+ return {
211
+ element: wrapper,
212
+ update: (v) => sliderUpdate(v as number),
213
+ };
214
+ }
215
+
216
+ // ===========================================================================
217
+ // Int Slider
218
+ // ===========================================================================
219
+
220
+ private createIntSlider(name: string, def: IntUniformDefinition): { element: HTMLElement; update: (v: UniformValue) => void } {
221
+ const { element, update: sliderUpdate } = this.createSliderRow({
222
+ label: def.label ?? name,
223
+ min: def.min ?? 0,
224
+ max: def.max ?? 10,
225
+ step: def.step ?? 1,
226
+ value: this.values[name] as number,
227
+ format: (v) => String(Math.round(v)),
228
+ onInput: (v) => {
229
+ const intVal = Math.round(v);
230
+ this.values[name] = intVal;
231
+ this.onChange(name, intVal);
232
+ },
233
+ });
234
+
235
+ const wrapper = document.createElement('div');
236
+ wrapper.className = 'uniform-control uniform-control-int';
237
+ wrapper.appendChild(element);
238
+
239
+ return {
240
+ element: wrapper,
241
+ update: (v) => sliderUpdate(v as number),
242
+ };
243
+ }
244
+
245
+ // ===========================================================================
246
+ // Bool Toggle
247
+ // ===========================================================================
248
+
249
+ private createBoolToggle(name: string, def: BoolUniformDefinition): { element: HTMLElement; update: (v: UniformValue) => void } {
250
+ const value = this.values[name] as boolean;
251
+ const label = def.label ?? name;
252
+
253
+ const wrapper = document.createElement('div');
254
+ wrapper.className = 'uniform-control uniform-control-bool';
255
+
256
+ const labelRow = document.createElement('div');
257
+ labelRow.className = 'uniform-control-label-row';
258
+
259
+ const labelEl = document.createElement('label');
260
+ labelEl.className = 'uniform-control-label';
261
+ labelEl.textContent = label;
262
+
263
+ const toggleWrapper = document.createElement('label');
264
+ toggleWrapper.className = 'uniform-control-toggle';
265
+
266
+ const checkbox = document.createElement('input');
267
+ checkbox.type = 'checkbox';
268
+ checkbox.checked = value;
269
+
270
+ const slider = document.createElement('span');
271
+ slider.className = 'uniform-control-toggle-slider';
272
+
273
+ checkbox.addEventListener('change', () => {
274
+ const newValue = checkbox.checked;
275
+ this.values[name] = newValue;
276
+ this.onChange(name, newValue);
277
+ });
278
+
279
+ toggleWrapper.appendChild(checkbox);
280
+ toggleWrapper.appendChild(slider);
281
+
282
+ labelRow.appendChild(labelEl);
283
+ labelRow.appendChild(toggleWrapper);
284
+
285
+ wrapper.appendChild(labelRow);
286
+
287
+ return {
288
+ element: wrapper,
289
+ update: (v) => { checkbox.checked = v as boolean; },
290
+ };
291
+ }
292
+
293
+ // ===========================================================================
294
+ // Vec2 XY Pad
295
+ // ===========================================================================
296
+
297
+ private createVec2Pad(name: string, def: Vec2UniformDefinition): { element: HTMLElement; update: (v: UniformValue) => void } {
298
+ const value = this.values[name] as number[];
299
+ const min = def.min ?? [0, 0];
300
+ const max = def.max ?? [1, 1];
301
+ const label = def.label ?? name;
302
+
303
+ const wrapper = document.createElement('div');
304
+ wrapper.className = 'uniform-control uniform-control-vec2';
305
+
306
+ const labelRow = document.createElement('div');
307
+ labelRow.className = 'uniform-control-label-row';
308
+
309
+ const labelEl = document.createElement('label');
310
+ labelEl.className = 'uniform-control-label';
311
+ labelEl.textContent = label;
312
+
313
+ const valueDisplay = document.createElement('span');
314
+ valueDisplay.className = 'uniform-control-value';
315
+ valueDisplay.textContent = this.formatVec2(value);
316
+
317
+ labelRow.appendChild(labelEl);
318
+ labelRow.appendChild(valueDisplay);
319
+
320
+ const padContainer = document.createElement('div');
321
+ padContainer.className = 'uniform-control-xy-pad';
322
+
323
+ const handle = document.createElement('div');
324
+ handle.className = 'uniform-control-xy-handle';
325
+
326
+ padContainer.appendChild(handle);
327
+
328
+ const positionHandle = (v: number[]) => {
329
+ const xPercent = ((v[0] - min[0]) / (max[0] - min[0])) * 100;
330
+ const yPercent = (1 - (v[1] - min[1]) / (max[1] - min[1])) * 100;
331
+ handle.style.left = `${xPercent}%`;
332
+ handle.style.top = `${yPercent}%`;
333
+ };
334
+ positionHandle(value);
335
+
336
+ let isDragging = false;
337
+
338
+ const updateFromEvent = (e: MouseEvent | TouchEvent) => {
339
+ const rect = padContainer.getBoundingClientRect();
340
+ const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
341
+ const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
342
+
343
+ let xPercent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
344
+ let yPercent = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
345
+
346
+ const newX = min[0] + xPercent * (max[0] - min[0]);
347
+ const newY = min[1] + (1 - yPercent) * (max[1] - min[1]);
348
+
349
+ const newValue = [newX, newY];
350
+ this.values[name] = newValue;
351
+
352
+ handle.style.left = `${xPercent * 100}%`;
353
+ handle.style.top = `${yPercent * 100}%`;
354
+ valueDisplay.textContent = this.formatVec2(newValue);
355
+
356
+ this.onChange(name, newValue);
357
+ };
358
+
359
+ const onMouseDown = (e: MouseEvent) => { isDragging = true; updateFromEvent(e); e.preventDefault(); };
360
+ const onMouseMove = (e: Event) => { if (isDragging) updateFromEvent(e as MouseEvent); };
361
+ const onMouseUp = () => { isDragging = false; };
362
+
363
+ padContainer.addEventListener('mousedown', onMouseDown);
364
+ document.addEventListener('mousemove', onMouseMove);
365
+ document.addEventListener('mouseup', onMouseUp);
366
+ this.documentListeners.push({ type: 'mousemove', handler: onMouseMove });
367
+ this.documentListeners.push({ type: 'mouseup', handler: onMouseUp });
368
+
369
+ const onTouchStart = (e: TouchEvent) => { isDragging = true; updateFromEvent(e); e.preventDefault(); };
370
+ const onTouchMove = (e: TouchEvent) => { if (isDragging) updateFromEvent(e); };
371
+
372
+ padContainer.addEventListener('touchstart', onTouchStart);
373
+ document.addEventListener('touchmove', onTouchMove as EventListener);
374
+ document.addEventListener('touchend', onMouseUp);
375
+ this.documentListeners.push({ type: 'touchmove', handler: onTouchMove as EventListener });
376
+ this.documentListeners.push({ type: 'touchend', handler: onMouseUp });
377
+
378
+ wrapper.appendChild(labelRow);
379
+ wrapper.appendChild(padContainer);
380
+
381
+ return {
382
+ element: wrapper,
383
+ update: (v) => {
384
+ const vec = v as number[];
385
+ positionHandle(vec);
386
+ valueDisplay.textContent = this.formatVec2(vec);
387
+ },
388
+ };
389
+ }
390
+
391
+ // ===========================================================================
392
+ // Vec3 Color Picker
393
+ // ===========================================================================
394
+
395
+ private createColorPicker(name: string, def: Vec3UniformDefinition): { element: HTMLElement; update: (v: UniformValue) => void } {
396
+ const value = this.values[name] as number[];
397
+ const label = def.label ?? name;
398
+
399
+ const wrapper = document.createElement('div');
400
+ wrapper.className = 'uniform-control uniform-control-color';
401
+
402
+ const labelRow = document.createElement('div');
403
+ labelRow.className = 'uniform-control-label-row';
404
+
405
+ const labelEl = document.createElement('label');
406
+ labelEl.className = 'uniform-control-label';
407
+ labelEl.textContent = label;
408
+
409
+ const valueDisplay = document.createElement('span');
410
+ valueDisplay.className = 'uniform-control-value';
411
+ valueDisplay.textContent = this.rgbToHex(value);
412
+
413
+ labelRow.appendChild(labelEl);
414
+ labelRow.appendChild(valueDisplay);
415
+
416
+ const colorWrapper = document.createElement('div');
417
+ colorWrapper.className = 'uniform-control-color-wrapper';
418
+
419
+ const colorInput = document.createElement('input');
420
+ colorInput.type = 'color';
421
+ colorInput.className = 'uniform-control-color-input';
422
+ colorInput.value = this.rgbToHex(value);
423
+
424
+ const swatch = document.createElement('div');
425
+ swatch.className = 'uniform-control-color-swatch';
426
+ swatch.style.backgroundColor = this.rgbToHex(value);
427
+
428
+ colorInput.addEventListener('input', () => {
429
+ const newValue = this.hexToRgb(colorInput.value);
430
+ this.values[name] = newValue;
431
+ valueDisplay.textContent = colorInput.value;
432
+ swatch.style.backgroundColor = colorInput.value;
433
+ this.onChange(name, newValue);
434
+ });
435
+
436
+ swatch.addEventListener('click', () => colorInput.click());
437
+
438
+ colorWrapper.appendChild(swatch);
439
+ colorWrapper.appendChild(colorInput);
440
+
441
+ wrapper.appendChild(labelRow);
442
+ wrapper.appendChild(colorWrapper);
443
+
444
+ return {
445
+ element: wrapper,
446
+ update: (v) => {
447
+ const hex = this.rgbToHex(v as number[]);
448
+ colorInput.value = hex;
449
+ swatch.style.backgroundColor = hex;
450
+ valueDisplay.textContent = hex;
451
+ },
452
+ };
453
+ }
454
+
455
+ // ===========================================================================
456
+ // Vec4 Color Picker (with alpha)
457
+ // ===========================================================================
458
+
459
+ private createColorPicker4(name: string, def: Vec4UniformDefinition): { element: HTMLElement; update: (v: UniformValue) => void } {
460
+ // For vec4 color, use color picker for RGB + a slider for alpha
461
+ const value = this.values[name] as number[];
462
+ const label = def.label ?? name;
463
+
464
+ const wrapper = document.createElement('div');
465
+ wrapper.className = 'uniform-control uniform-control-color';
466
+
467
+ // Color picker for RGB
468
+ const colorResult = this.createColorPicker(name, {
469
+ type: 'vec3',
470
+ value: [value[0], value[1], value[2]],
471
+ color: true,
472
+ label,
473
+ });
474
+
475
+ // Alpha slider
476
+ const alphaStep = def.step?.[3] ?? 0.01;
477
+ const { element: alphaEl, update: alphaUpdate } = this.createSliderRow({
478
+ label: 'Alpha',
479
+ min: def.min?.[3] ?? 0,
480
+ max: def.max?.[3] ?? 1,
481
+ step: alphaStep,
482
+ value: value[3],
483
+ format: (v) => this.formatNumber(v, alphaStep),
484
+ onInput: (v) => {
485
+ const current = this.values[name] as number[];
486
+ current[3] = v;
487
+ this.onChange(name, [...current]);
488
+ },
489
+ });
490
+
491
+ // Override the color picker's onChange to include alpha
492
+ const origColorInput = colorResult.element.querySelector('.uniform-control-color-input') as HTMLInputElement;
493
+ if (origColorInput) {
494
+ // Remove old listener by replacing element
495
+ const newInput = origColorInput.cloneNode(true) as HTMLInputElement;
496
+ origColorInput.parentNode!.replaceChild(newInput, origColorInput);
497
+ const swatch = colorResult.element.querySelector('.uniform-control-color-swatch') as HTMLElement;
498
+ const valueDisplay = colorResult.element.querySelector('.uniform-control-value') as HTMLElement;
499
+
500
+ newInput.addEventListener('input', () => {
501
+ const rgb = this.hexToRgb(newInput.value);
502
+ const current = this.values[name] as number[];
503
+ current[0] = rgb[0]; current[1] = rgb[1]; current[2] = rgb[2];
504
+ if (valueDisplay) valueDisplay.textContent = newInput.value;
505
+ if (swatch) swatch.style.backgroundColor = newInput.value;
506
+ this.onChange(name, [...current]);
507
+ });
508
+
509
+ if (swatch) swatch.addEventListener('click', () => newInput.click());
510
+ }
511
+
512
+ wrapper.appendChild(colorResult.element.querySelector('.uniform-control-label-row')!);
513
+ wrapper.appendChild(colorResult.element.querySelector('.uniform-control-color-wrapper')!);
514
+ wrapper.appendChild(alphaEl);
515
+
516
+ return {
517
+ element: wrapper,
518
+ update: (v) => {
519
+ const vec = v as number[];
520
+ colorResult.update([vec[0], vec[1], vec[2]]);
521
+ alphaUpdate(vec[3]);
522
+ },
523
+ };
524
+ }
525
+
526
+ // ===========================================================================
527
+ // Vec3/Vec4 Component Sliders
528
+ // ===========================================================================
529
+
530
+ private createVecSliders(name: string, def: Vec3UniformDefinition | Vec4UniformDefinition, count: 3 | 4): { element: HTMLElement; update: (v: UniformValue) => void } {
531
+ const value = this.values[name] as number[];
532
+ const label = def.label ?? name;
533
+ const components = count === 3 ? ['X', 'Y', 'Z'] : ['X', 'Y', 'Z', 'W'];
534
+
535
+ const wrapper = document.createElement('div');
536
+ wrapper.className = `uniform-control uniform-control-vec${count}`;
537
+
538
+ const labelEl = document.createElement('div');
539
+ labelEl.className = 'uniform-control-label';
540
+ labelEl.textContent = label;
541
+ wrapper.appendChild(labelEl);
542
+
543
+ const sliderUpdaters: Array<(v: number) => void> = [];
544
+
545
+ components.forEach((comp, i) => {
546
+ const step = def.step?.[i] ?? 0.01;
547
+ const { element: row, update: rowUpdate } = this.createSliderRow({
548
+ label: comp,
549
+ min: def.min?.[i] ?? 0,
550
+ max: def.max?.[i] ?? 1,
551
+ step,
552
+ value: value[i],
553
+ format: (v) => this.formatNumber(v, step),
554
+ onInput: (v) => {
555
+ const currentValue = this.values[name] as number[];
556
+ currentValue[i] = v;
557
+ this.onChange(name, [...currentValue]);
558
+ },
559
+ });
560
+
561
+ // Style the row for vec component layout
562
+ const labelRow = row.querySelector('.uniform-control-label-row');
563
+ if (labelRow) {
564
+ labelRow.classList.add('uniform-control-vec-slider-row');
565
+ const lbl = labelRow.querySelector('.uniform-control-label');
566
+ if (lbl) {
567
+ lbl.classList.add('uniform-control-vec-component');
568
+ }
569
+ const val = labelRow.querySelector('.uniform-control-value');
570
+ if (val) {
571
+ val.classList.add('uniform-control-vec-value');
572
+ }
573
+ }
574
+ const slider = row.querySelector('.uniform-control-slider');
575
+ if (slider) {
576
+ slider.classList.add('uniform-control-vec-slider');
577
+ }
578
+
579
+ sliderUpdaters.push(rowUpdate);
580
+ wrapper.appendChild(row);
581
+ });
582
+
583
+ return {
584
+ element: wrapper,
585
+ update: (v) => {
586
+ const vec = v as number[];
587
+ sliderUpdaters.forEach((upd, i) => upd(vec[i]));
588
+ },
589
+ };
590
+ }
591
+
592
+ // ===========================================================================
593
+ // Utility Methods
594
+ // ===========================================================================
595
+
596
+ private formatNumber(value: number, step: number): string {
597
+ const stepStr = String(step);
598
+ const decimalIndex = stepStr.indexOf('.');
599
+ const decimals = decimalIndex === -1 ? 0 : stepStr.length - decimalIndex - 1;
600
+ return value.toFixed(decimals);
601
+ }
602
+
603
+ private formatVec2(value: number[]): string {
604
+ return `(${value[0].toFixed(2)}, ${value[1].toFixed(2)})`;
605
+ }
606
+
607
+ private rgbToHex(rgb: number[]): string {
608
+ const r = Math.round(rgb[0] * 255);
609
+ const g = Math.round(rgb[1] * 255);
610
+ const b = Math.round(rgb[2] * 255);
611
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
612
+ }
613
+
614
+ private hexToRgb(hex: string): number[] {
615
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
616
+ if (!result) return [0, 0, 0];
617
+ return [
618
+ parseInt(result[1], 16) / 255,
619
+ parseInt(result[2], 16) / 255,
620
+ parseInt(result[3], 16) / 255,
621
+ ];
622
+ }
623
+
624
+ // ===========================================================================
625
+ // Public Methods
626
+ // ===========================================================================
627
+
628
+ /**
629
+ * Update a uniform value externally (e.g., from reset).
630
+ */
631
+ setValue(name: string, value: UniformValue): void {
632
+ if (!(name in this.uniforms)) return;
633
+ this.values[name] = value;
634
+ this.updaters.get(name)?.(value);
635
+ }
636
+
637
+ /**
638
+ * Reset all uniforms to their default values.
639
+ */
640
+ resetToDefaults(): void {
641
+ for (const [name, def] of Object.entries(this.uniforms)) {
642
+ if (isArrayUniform(def) || def.hidden) continue;
643
+ this.setValue(name, def.value);
644
+ this.onChange(name, def.value);
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Destroy the controls and clean up.
650
+ */
651
+ destroy(): void {
652
+ for (const { type, handler } of this.documentListeners) {
653
+ document.removeEventListener(type, handler);
654
+ }
655
+ this.documentListeners = [];
656
+
657
+ this.container.innerHTML = '';
658
+ this.updaters.clear();
659
+ }
660
+ }