@solidtv/renderer 1.1.4 → 1.2.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.
Files changed (67) hide show
  1. package/dist/exports/canvas-shaders.d.ts +1 -0
  2. package/dist/exports/canvas-shaders.js +1 -0
  3. package/dist/exports/canvas-shaders.js.map +1 -1
  4. package/dist/exports/webgl-shaders.d.ts +1 -0
  5. package/dist/exports/webgl-shaders.js +1 -0
  6. package/dist/exports/webgl-shaders.js.map +1 -1
  7. package/dist/src/core/CoreNode.js +4 -2
  8. package/dist/src/core/CoreNode.js.map +1 -1
  9. package/dist/src/core/CoreTextNode.js +9 -7
  10. package/dist/src/core/CoreTextNode.js.map +1 -1
  11. package/dist/src/core/Stage.js +6 -0
  12. package/dist/src/core/Stage.js.map +1 -1
  13. package/dist/src/core/renderers/webgl/WebGlRenderer.js +11 -8
  14. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  15. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +3 -2
  16. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  17. package/dist/src/core/shaders/canvas/RadialProgress.d.ts +10 -0
  18. package/dist/src/core/shaders/canvas/RadialProgress.js +87 -0
  19. package/dist/src/core/shaders/canvas/RadialProgress.js.map +1 -0
  20. package/dist/src/core/shaders/templates/RadialProgressTemplate.d.ts +61 -0
  21. package/dist/src/core/shaders/templates/RadialProgressTemplate.js +54 -0
  22. package/dist/src/core/shaders/templates/RadialProgressTemplate.js.map +1 -0
  23. package/dist/src/core/shaders/webgl/Border.js +6 -4
  24. package/dist/src/core/shaders/webgl/Border.js.map +1 -1
  25. package/dist/src/core/shaders/webgl/RadialProgress.d.ts +3 -0
  26. package/dist/src/core/shaders/webgl/RadialProgress.js +145 -0
  27. package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -0
  28. package/dist/src/core/shaders/webgl/RoundedWithBorder.js +6 -4
  29. package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
  30. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +6 -4
  31. package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
  32. package/dist/src/core/text-rendering/CanvasTextRenderer.js +5 -2
  33. package/dist/src/core/text-rendering/CanvasTextRenderer.js.map +1 -1
  34. package/dist/src/core/text-rendering/SdfFontHandler.js +35 -0
  35. package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
  36. package/dist/src/core/text-rendering/SdfTextRenderer.js +21 -20
  37. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  38. package/dist/src/core/text-rendering/TextLayoutEngine.d.ts +7 -1
  39. package/dist/src/core/text-rendering/TextLayoutEngine.js +94 -10
  40. package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
  41. package/dist/src/core/text-rendering/TextRenderer.d.ts +61 -10
  42. package/dist/src/main-api/Renderer.d.ts +23 -1
  43. package/dist/src/main-api/Renderer.js +6 -1
  44. package/dist/src/main-api/Renderer.js.map +1 -1
  45. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  46. package/exports/canvas-shaders.ts +1 -0
  47. package/exports/webgl-shaders.ts +1 -0
  48. package/package.json +1 -1
  49. package/src/core/CoreNode.ts +6 -2
  50. package/src/core/CoreTextNode.ts +9 -6
  51. package/src/core/Stage.ts +7 -0
  52. package/src/core/renderers/webgl/WebGlCtxTexture.test.ts +94 -0
  53. package/src/core/renderers/webgl/WebGlRenderer.ts +14 -10
  54. package/src/core/renderers/webgl/WebGlShaderProgram.ts +3 -2
  55. package/src/core/shaders/canvas/RadialProgress.ts +113 -0
  56. package/src/core/shaders/templates/RadialProgressTemplate.test.ts +125 -0
  57. package/src/core/shaders/templates/RadialProgressTemplate.ts +112 -0
  58. package/src/core/shaders/webgl/Border.ts +6 -4
  59. package/src/core/shaders/webgl/RadialProgress.ts +163 -0
  60. package/src/core/shaders/webgl/RoundedWithBorder.ts +6 -4
  61. package/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +6 -4
  62. package/src/core/text-rendering/CanvasTextRenderer.ts +5 -2
  63. package/src/core/text-rendering/SdfFontHandler.ts +37 -0
  64. package/src/core/text-rendering/SdfTextRenderer.ts +28 -22
  65. package/src/core/text-rendering/TextLayoutEngine.ts +99 -12
  66. package/src/core/text-rendering/TextRenderer.ts +62 -10
  67. package/src/main-api/Renderer.ts +33 -2
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests proving the WebGL OOM / ctxTexture undefined crash scenario.
3
+ *
4
+ * Crash chain being simulated:
5
+ * GL_OUT_OF_MEMORY from texImage2D
6
+ * → Texture.release() sets ctxTexture = undefined (via microtask)
7
+ * → WebGlRenderer.addQuad() reads tx.ctxTexture as WebGlCtxTexture (undefined)
8
+ * → CoreNode.addTexture(undefined) pushes undefined into renderOpTextures
9
+ * → WebGlShaderProgram.bindTextures() reads textures[0]!.ctxTexture
10
+ * → TypeError: Cannot read property 'ctxTexture' of undefined ← CRASH
11
+ *
12
+ * All tests here are RED before the fix and GREEN after.
13
+ */
14
+
15
+ import { describe, expect, it, vi } from 'vitest';
16
+ import { WebGlShaderProgram } from './WebGlShaderProgram.js';
17
+ import { CoreNode } from '../../CoreNode.js';
18
+ import type { WebGlCtxTexture } from './WebGlCtxTexture.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Test 1 — bindTextures crashes when textures[0] is undefined
22
+ //
23
+ // Directly mirrors the crash from the production stack trace:
24
+ // fb.bindTextures → textures[0]!.ctxTexture
25
+ // → TypeError: Cannot read property 'ctxTexture' of undefined
26
+ // ---------------------------------------------------------------------------
27
+ describe('WebGlShaderProgram.bindTextures', () => {
28
+ it('does not throw when textures[0] is undefined', () => {
29
+ // Create a minimal instance that bypasses the GL-context-requiring constructor
30
+ const instance = Object.create(
31
+ WebGlShaderProgram.prototype,
32
+ ) as WebGlShaderProgram;
33
+ (instance as any).glw = { activeTexture: vi.fn(), bindTexture: vi.fn() };
34
+
35
+ // After Fix B: bindTextures returns early instead of crashing
36
+ expect(() =>
37
+ instance.bindTextures([undefined as unknown as WebGlCtxTexture]),
38
+ ).not.toThrow();
39
+ });
40
+ });
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Test 2 — addTexture silently accepts undefined and stores it
44
+ //
45
+ // The crash is silent at this layer: passing an undefined ctxTexture to
46
+ // CoreNode.addTexture does NOT throw — it quietly pushes undefined into
47
+ // renderOpTextures, poisoning the array for bindTextures later.
48
+ // ---------------------------------------------------------------------------
49
+ describe('CoreNode.addTexture with undefined ctxTexture', () => {
50
+ it('accepts undefined without throwing and stores it in renderOpTextures', () => {
51
+ // Call via prototype to avoid the complex CoreNode constructor
52
+ const fakeCtx = { renderOpTextures: [] as WebGlCtxTexture[] };
53
+ const idx = CoreNode.prototype.addTexture.call(
54
+ fakeCtx,
55
+ undefined as unknown as WebGlCtxTexture,
56
+ );
57
+
58
+ // No error raised here — that is the problem
59
+ expect(idx).toBe(0);
60
+ // undefined is now stored; any subsequent bindTextures call will crash
61
+ expect(fakeCtx.renderOpTextures[0]).toBeUndefined();
62
+ });
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Test 3 — full crash chain: undefined ctxTexture → addTexture → bindTextures
67
+ //
68
+ // End-to-end proof that the chain fails before any fix is applied.
69
+ // ---------------------------------------------------------------------------
70
+ describe('full OOM crash chain: undefined ctxTexture → bindTextures crash', () => {
71
+ it('does not crash when undefined ctxTexture propagates to bindTextures', () => {
72
+ // Simulate: GPU OOM causes Texture.release() → ctxTexture = undefined
73
+ const undefinedCtxTexture = undefined as unknown as WebGlCtxTexture;
74
+
75
+ // Simulate: WebGlRenderer.addQuad reads tx.ctxTexture (undefined) and
76
+ // passes it to curRenderOp.addTexture — no error thrown here
77
+ const fakeRenderOp = { renderOpTextures: [] as WebGlCtxTexture[] };
78
+ CoreNode.prototype.addTexture.call(fakeRenderOp, undefinedCtxTexture);
79
+
80
+ // Confirm undefined is still sitting in renderOpTextures (addTexture is unchanged)
81
+ expect(fakeRenderOp.renderOpTextures[0]).toBeUndefined();
82
+
83
+ // Simulate: bindRenderOp calls bindTextures with the poisoned array
84
+ const program = Object.create(
85
+ WebGlShaderProgram.prototype,
86
+ ) as WebGlShaderProgram;
87
+ (program as any).glw = { activeTexture: vi.fn(), bindTexture: vi.fn() };
88
+
89
+ // After Fix B: bindTextures returns early — the draw loop is protected
90
+ expect(() =>
91
+ program.bindTextures(fakeRenderOp.renderOpTextures),
92
+ ).not.toThrow();
93
+ });
94
+ });
@@ -411,6 +411,18 @@ export class WebGlRenderer extends CoreRenderer {
411
411
  this.curSdfRenderOp = null;
412
412
  }
413
413
 
414
+ const props = node.props;
415
+ let tx = props.texture || this.stage.defaultTexture!;
416
+
417
+ if (tx.type === TextureType.subTexture) {
418
+ tx = (tx as SubTexture).parentTexture;
419
+ }
420
+
421
+ const ctx = tx.ctxTexture as WebGlCtxTexture | undefined;
422
+ if (ctx === undefined) {
423
+ return;
424
+ }
425
+
414
426
  const reuse = this.reuseRenderOp(node);
415
427
 
416
428
  // During RTT rendering, always use sequential allocation and write data
@@ -439,19 +451,11 @@ export class WebGlRenderer extends CoreRenderer {
439
451
  this.newRenderOp(node, i);
440
452
  }
441
453
 
442
- const props = node.props;
443
- let tx = props.texture || this.stage.defaultTexture!;
444
-
445
- if (tx.type === TextureType.subTexture) {
446
- tx = (tx as SubTexture).parentTexture;
447
- }
448
-
449
- const texture = tx.ctxTexture as WebGlCtxTexture;
450
- let tidx = this.curRenderOp!.addTexture(texture);
454
+ let tidx = this.curRenderOp!.addTexture(ctx);
451
455
 
452
456
  if (tidx === 0xffffffff) {
453
457
  this.newRenderOp(node, i);
454
- tidx = this.curRenderOp!.addTexture(texture);
458
+ tidx = this.curRenderOp!.addTexture(ctx);
455
459
  }
456
460
 
457
461
  // Only rewrite the CPU-side buffer when the node is dirty.
@@ -297,11 +297,12 @@ export class WebGlShaderProgram implements CoreShaderProgram {
297
297
  }
298
298
 
299
299
  bindTextures(textures: WebGlCtxTexture[]) {
300
- if (textures[0] === undefined) {
300
+ const t = textures[0];
301
+ if (t === undefined) {
301
302
  return;
302
303
  }
303
304
  this.glw.activeTexture(0);
304
- this.glw.bindTexture(textures[0].ctxTexture);
305
+ this.glw.bindTexture(t.ctxTexture);
305
306
  }
306
307
 
307
308
  attach(): void {
@@ -0,0 +1,113 @@
1
+ import type { CanvasShaderType } from '../../renderers/canvas/CanvasShaderNode.js';
2
+ import {
3
+ RadialProgressTemplate,
4
+ type RadialProgressProps,
5
+ } from '../templates/RadialProgressTemplate.js';
6
+
7
+ export interface ComputedRadialProgressValues {
8
+ cx: number;
9
+ cy: number;
10
+ radius: number;
11
+ colorChannels: number[][]; // [r,g,b,a] per stop color, 0..255 (a in 0..1)
12
+ trackColor: string | null;
13
+ }
14
+
15
+ const SEGMENTS = 64;
16
+
17
+ function lerpColor(a: number[], b: number[], t: number): string {
18
+ const r = Math.round(a[0]! + (b[0]! - a[0]!) * t);
19
+ const g = Math.round(a[1]! + (b[1]! - a[1]!) * t);
20
+ const bl = Math.round(a[2]! + (b[2]! - a[2]!) * t);
21
+ const al = a[3]! + (b[3]! - a[3]!) * t;
22
+ return `rgba(${r},${g},${bl},${al})`;
23
+ }
24
+
25
+ function colorAt(channels: number[][], stops: number[], t: number): string {
26
+ const last = channels.length - 1;
27
+ if (t <= stops[0]!) return lerpColor(channels[0]!, channels[0]!, 0);
28
+ if (t >= stops[last]!) return lerpColor(channels[last]!, channels[last]!, 0);
29
+ for (let i = 0; i < last; i++) {
30
+ const left = stops[i]!;
31
+ const right = stops[i + 1]!;
32
+ if (t >= left && t <= right) {
33
+ const lt = (t - left) / (right - left);
34
+ return lerpColor(channels[i]!, channels[i + 1]!, lt);
35
+ }
36
+ }
37
+ return lerpColor(channels[last]!, channels[last]!, 0);
38
+ }
39
+
40
+ function toChannels(rgba: number): number[] {
41
+ return [
42
+ (rgba >>> 24) & 0xff,
43
+ (rgba >>> 16) & 0xff,
44
+ (rgba >>> 8) & 0xff,
45
+ (rgba & 0xff) / 255,
46
+ ];
47
+ }
48
+
49
+ export const RadialProgress: CanvasShaderType<
50
+ RadialProgressProps,
51
+ ComputedRadialProgressValues
52
+ > = {
53
+ props: RadialProgressTemplate.props,
54
+ update(node) {
55
+ const props = this.props!;
56
+ const autoRadius = Math.min(node.w, node.h) * 0.5 - props.width * 0.5;
57
+ const radius = props.radius > 0 ? props.radius : autoRadius;
58
+
59
+ const colorChannels: number[][] = [];
60
+ for (let i = 0; i < props.colors.length; i++) {
61
+ colorChannels.push(toChannels(props.colors[i]!));
62
+ }
63
+
64
+ this.computed = {
65
+ cx: node.w * 0.5,
66
+ cy: node.h * 0.5,
67
+ radius,
68
+ colorChannels,
69
+ trackColor:
70
+ props.trackColor !== 0 ? this.toColorString(props.trackColor) : null,
71
+ };
72
+ },
73
+ render(ctx, node, renderContext) {
74
+ renderContext();
75
+ const { cx, cy, radius, colorChannels, trackColor } = this
76
+ .computed as ComputedRadialProgressValues;
77
+ const { tx, ty } = node.globalTransform!;
78
+ const props = this.props!;
79
+ const { width, progress, startAngle, direction, cap } = props;
80
+ const stops = props.stops;
81
+
82
+ const ax = tx + cx;
83
+ const ay = ty + cy;
84
+
85
+ ctx.lineWidth = width;
86
+ ctx.lineCap = cap === 1 ? 'round' : 'butt';
87
+
88
+ if (trackColor !== null) {
89
+ ctx.strokeStyle = trackColor;
90
+ ctx.beginPath();
91
+ ctx.arc(ax, ay, radius, 0, Math.PI * 2);
92
+ ctx.stroke();
93
+ }
94
+
95
+ if (progress <= 0) return;
96
+
97
+ const sweep = Math.PI * 2 * progress * direction;
98
+ const step = sweep / SEGMENTS;
99
+ // Overlap segments by a tiny amount so the seams don't show on canvas AA
100
+ const overlap = Math.abs(step) * 0.02;
101
+
102
+ for (let i = 0; i < SEGMENTS; i++) {
103
+ const t = i / (SEGMENTS - 1);
104
+ ctx.strokeStyle = colorAt(colorChannels, stops, t);
105
+ ctx.beginPath();
106
+ const a0 = startAngle + step * i;
107
+ const a1 =
108
+ startAngle + step * (i + 1) + (direction === 1 ? overlap : -overlap);
109
+ ctx.arc(ax, ay, radius, a0, a1, direction === -1);
110
+ ctx.stroke();
111
+ }
112
+ },
113
+ };
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ RadialProgressTemplate,
4
+ type RadialProgressProps,
5
+ } from './RadialProgressTemplate.js';
6
+ import {
7
+ isAdvancedShaderProp,
8
+ resolveShaderProps,
9
+ } from '../../renderers/CoreShaderNode.js';
10
+
11
+ function resolve(input: Partial<RadialProgressProps>): RadialProgressProps {
12
+ const props = { ...input } as Record<string, unknown>;
13
+ resolveShaderProps(props, RadialProgressTemplate.props as never);
14
+ return props as unknown as RadialProgressProps;
15
+ }
16
+
17
+ describe('RadialProgressTemplate', () => {
18
+ describe('progress', () => {
19
+ const cfg = RadialProgressTemplate.props!.progress;
20
+ if (!isAdvancedShaderProp(cfg))
21
+ throw new Error('progress should be advanced');
22
+
23
+ it('clamps values below 0 to 0', () => {
24
+ expect(cfg.resolve!(-0.5, {} as never)).toBe(0);
25
+ });
26
+
27
+ it('clamps values above 1 to 1', () => {
28
+ expect(cfg.resolve!(2, {} as never)).toBe(1);
29
+ });
30
+
31
+ it('passes through in-range values', () => {
32
+ expect(cfg.resolve!(0.42, {} as never)).toBe(0.42);
33
+ });
34
+
35
+ it('returns default when undefined', () => {
36
+ expect(cfg.resolve!(undefined as never, {} as never)).toBe(1);
37
+ });
38
+ });
39
+
40
+ describe('colors', () => {
41
+ const cfg = RadialProgressTemplate.props!.colors;
42
+ if (!isAdvancedShaderProp(cfg))
43
+ throw new Error('colors should be advanced');
44
+
45
+ it('falls back to default on undefined', () => {
46
+ expect(cfg.resolve!(undefined as never, {} as never)).toEqual([
47
+ 0xffffffff,
48
+ ]);
49
+ });
50
+
51
+ it('falls back to default on empty array', () => {
52
+ expect(cfg.resolve!([] as never, {} as never)).toEqual([0xffffffff]);
53
+ });
54
+
55
+ it('passes through user-provided colors', () => {
56
+ const input = [0xff0000ff, 0x00ff00ff];
57
+ expect(cfg.resolve!(input, {} as never)).toEqual(input);
58
+ });
59
+ });
60
+
61
+ describe('stops', () => {
62
+ const cfg = RadialProgressTemplate.props!.stops;
63
+ if (!isAdvancedShaderProp(cfg)) throw new Error('stops should be advanced');
64
+
65
+ it('auto-distributes when omitted (n=3)', () => {
66
+ const out = cfg.resolve!(
67
+ undefined as never,
68
+ {
69
+ colors: [1, 2, 3],
70
+ } as never,
71
+ );
72
+ expect(out).toEqual([0, 0.5, 1]);
73
+ });
74
+
75
+ it('auto-distributes when length mismatches', () => {
76
+ const out = cfg.resolve!(
77
+ [0, 1] as never,
78
+ {
79
+ colors: [1, 2, 3],
80
+ } as never,
81
+ );
82
+ expect(out).toEqual([0, 0.5, 1]);
83
+ });
84
+
85
+ it('handles single color (n=1) without NaN', () => {
86
+ const out = cfg.resolve!(undefined as never, { colors: [1] } as never);
87
+ expect(out).toEqual([0]);
88
+ });
89
+
90
+ it('passes through valid stops', () => {
91
+ const out = cfg.resolve!(
92
+ [0, 0.3, 1] as never,
93
+ {
94
+ colors: [1, 2, 3],
95
+ } as never,
96
+ );
97
+ expect(out).toEqual([0, 0.3, 1]);
98
+ });
99
+ });
100
+
101
+ describe('defaults via resolveShaderProps', () => {
102
+ it('applies all defaults when no props given', () => {
103
+ const r = resolve({});
104
+ expect(r.width).toBe(8);
105
+ expect(r.radius).toBe(0);
106
+ expect(r.progress).toBe(1);
107
+ expect(r.startAngle).toBeCloseTo(-Math.PI / 2);
108
+ expect(r.direction).toBe(1);
109
+ expect(r.colors).toEqual([0xffffffff]);
110
+ expect(r.stops).toEqual([0]);
111
+ expect(r.trackColor).toBe(0x00000000);
112
+ expect(r.cap).toBe(1);
113
+ });
114
+
115
+ it('clamps progress through full resolution path', () => {
116
+ const r = resolve({ progress: 1.5 });
117
+ expect(r.progress).toBe(1);
118
+ });
119
+
120
+ it('auto-distributes stops through full resolution path', () => {
121
+ const r = resolve({ colors: [0xff0000ff, 0x00ff00ff, 0x0000ffff] });
122
+ expect(r.stops).toEqual([0, 0.5, 1]);
123
+ });
124
+ });
125
+ });
@@ -0,0 +1,112 @@
1
+ import type { CoreShaderType } from '../../renderers/CoreShaderNode.js';
2
+
3
+ /**
4
+ * Properties of the {@link RadialProgress} shader
5
+ */
6
+ export interface RadialProgressProps {
7
+ /**
8
+ * Stroke width of the ring in pixels
9
+ *
10
+ * @default 8
11
+ */
12
+ width: number;
13
+ /**
14
+ * Outer radius of the ring in pixels. When 0, auto-fits the node:
15
+ * `min(node.w, node.h) / 2 - width / 2`
16
+ *
17
+ * @default 0
18
+ */
19
+ radius: number;
20
+ /**
21
+ * Portion of the ring that is filled, in `[0, 1]`
22
+ *
23
+ * @default 1
24
+ */
25
+ progress: number;
26
+ /**
27
+ * Angle (in radians) where the filled arc starts. `-PI/2` is 12 o'clock.
28
+ *
29
+ * @default -Math.PI / 2
30
+ */
31
+ startAngle: number;
32
+ /**
33
+ * Sweep direction. `1` = clockwise, `-1` = counter-clockwise.
34
+ *
35
+ * @default 1
36
+ */
37
+ direction: 1 | -1;
38
+ /**
39
+ * Colors swept along the filled arc.
40
+ *
41
+ * @default [0xffffffff]
42
+ */
43
+ colors: number[];
44
+ /**
45
+ * Color stops along the filled arc, in `[0, 1]`. Auto-distributed when omitted
46
+ * or when length doesn't match `colors`.
47
+ */
48
+ stops: number[];
49
+ /**
50
+ * Background ring color (drawn under the full circle). `0x00000000` disables.
51
+ *
52
+ * @default 0x00000000
53
+ */
54
+ trackColor: number;
55
+ /**
56
+ * Arc end-cap style. `0` = butt, `1` = round.
57
+ *
58
+ * @default 1
59
+ */
60
+ cap: 0 | 1;
61
+ }
62
+
63
+ export const RadialProgressTemplate: CoreShaderType<RadialProgressProps> = {
64
+ props: {
65
+ width: 8,
66
+ radius: 0,
67
+ progress: {
68
+ default: 1,
69
+ resolve(value) {
70
+ if (value === undefined) return this.default;
71
+ if (value < 0) return 0;
72
+ if (value > 1) return 1;
73
+ return value;
74
+ },
75
+ },
76
+ startAngle: -Math.PI / 2,
77
+ direction: 1,
78
+ colors: {
79
+ default: [0xffffffff],
80
+ resolve(value) {
81
+ if (value !== undefined && value.length > 0) {
82
+ return value;
83
+ }
84
+ return ([] as number[]).concat(this.default);
85
+ },
86
+ },
87
+ stops: {
88
+ default: [0],
89
+ resolve(value, props) {
90
+ if (value !== undefined && value.length === props.colors.length) {
91
+ return value;
92
+ }
93
+ if (value === undefined) {
94
+ value = [];
95
+ }
96
+ const len = props.colors.length;
97
+ if (len === 1) {
98
+ value[0] = 0;
99
+ value.length = 1;
100
+ return value;
101
+ }
102
+ for (let i = 0; i < len; i++) {
103
+ value[i] = i * (1 / (len - 1));
104
+ }
105
+ value.length = len;
106
+ return value;
107
+ },
108
+ },
109
+ trackColor: 0x00000000,
110
+ cap: 1,
111
+ },
112
+ };
@@ -47,10 +47,12 @@ export const Border: WebGlShaderType<BorderProps> = {
47
47
  vec2 screenSpace = vec2(2.0 / u_resolution.x, -2.0 / u_resolution.y);
48
48
  vec2 edge = clamp(a_nodeCoords * 2.0 - vec2(1.0), -1.0, 1.0);
49
49
 
50
- float borderTop = u_borderWidth.x;
51
- float borderRight = u_borderWidth.y;
52
- float borderBottom = u_borderWidth.z;
53
- float borderLeft = u_borderWidth.w;
50
+ vec4 adjustedBorderWidth = u_borderWidth - 1.0 + clamp(u_borderWidth, -1.0, 1.0);
51
+
52
+ float borderTop = adjustedBorderWidth.x;
53
+ float borderRight = adjustedBorderWidth.y;
54
+ float borderBottom = adjustedBorderWidth.z;
55
+ float borderLeft = adjustedBorderWidth.w;
54
56
 
55
57
  v_outerBorderUv = vec2(0.0);
56
58
  v_innerBorderUv = vec2(0.0);
@@ -0,0 +1,163 @@
1
+ import type { CoreNode } from '../../CoreNode.js';
2
+ import { getNormalizedRgbaComponents } from '../../lib/utils.js';
3
+ import {
4
+ RadialProgressTemplate,
5
+ type RadialProgressProps,
6
+ } from '../templates/RadialProgressTemplate.js';
7
+ import type { WebGlShaderType } from '../../renderers/webgl/WebGlShaderNode.js';
8
+ import type { WebGlRenderer } from '../../renderers/webgl/WebGlRenderer.js';
9
+
10
+ export const RadialProgress: WebGlShaderType<RadialProgressProps> = {
11
+ props: RadialProgressTemplate.props,
12
+ update(node: CoreNode) {
13
+ const props = this.props!;
14
+
15
+ const autoRadius = Math.min(node.w, node.h) * 0.5 - props.width * 0.5;
16
+ const radius = props.radius > 0 ? props.radius : autoRadius;
17
+
18
+ this.uniform2f('u_center', node.w * 0.5, node.h * 0.5);
19
+ this.uniform1f('u_radius', radius);
20
+ this.uniform1f('u_width', props.width);
21
+ this.uniform1f('u_progress', props.progress);
22
+ this.uniform1f('u_startAngle', props.startAngle);
23
+ this.uniform1f('u_direction', props.direction);
24
+ this.uniform1fv('u_stops', new Float32Array(props.stops));
25
+
26
+ const colors: number[] = [];
27
+ for (let i = 0; i < props.colors.length; i++) {
28
+ const norm = getNormalizedRgbaComponents(props.colors[i]!);
29
+ colors.push(norm[0]!, norm[1]!, norm[2]!, norm[3]!);
30
+ }
31
+ this.uniform4fv('u_colors', new Float32Array(colors));
32
+
33
+ const trackNorm = getNormalizedRgbaComponents(props.trackColor);
34
+ this.uniform4f(
35
+ 'u_trackColor',
36
+ trackNorm[0]!,
37
+ trackNorm[1]!,
38
+ trackNorm[2]!,
39
+ trackNorm[3]!,
40
+ );
41
+ },
42
+ getCacheMarkers(props: RadialProgressProps) {
43
+ return `colors:${props.colors.length}|cap:${props.cap}|track:${
44
+ props.trackColor !== 0 ? 1 : 0
45
+ }`;
46
+ },
47
+ fragment(renderer: WebGlRenderer, props: RadialProgressProps) {
48
+ const maxStops = Math.max(props.colors.length, 1);
49
+ return `
50
+ # ifdef GL_FRAGMENT_PRECISION_HIGH
51
+ precision highp float;
52
+ # else
53
+ precision mediump float;
54
+ # endif
55
+
56
+ #define MAX_STOPS ${maxStops}
57
+ #define LAST_STOP ${maxStops - 1}
58
+ #define CAP_ROUND ${props.cap}
59
+ #define HAS_TRACK ${props.trackColor !== 0 ? 1 : 0}
60
+
61
+ #define TWO_PI 6.28318530717958647692
62
+
63
+ uniform float u_alpha;
64
+ uniform vec2 u_dimensions;
65
+ uniform sampler2D u_texture;
66
+
67
+ uniform vec2 u_center;
68
+ uniform float u_radius;
69
+ uniform float u_width;
70
+ uniform float u_progress;
71
+ uniform float u_startAngle;
72
+ uniform float u_direction;
73
+
74
+ uniform float u_stops[MAX_STOPS];
75
+ uniform vec4 u_colors[MAX_STOPS];
76
+ uniform vec4 u_trackColor;
77
+
78
+ varying vec4 v_color;
79
+ varying vec2 v_textureCoords;
80
+ varying vec2 v_nodeCoords;
81
+
82
+ vec4 getGradientColor(float dist) {
83
+ dist = clamp(dist, 0.0, 1.0);
84
+
85
+ if (dist <= u_stops[0]) {
86
+ return u_colors[0];
87
+ }
88
+ if (dist >= u_stops[LAST_STOP]) {
89
+ return u_colors[LAST_STOP];
90
+ }
91
+ for (int i = 0; i < LAST_STOP; i++) {
92
+ float left = u_stops[i];
93
+ float right = u_stops[i + 1];
94
+ if (dist >= left && dist <= right) {
95
+ float lDist = smoothstep(left, right, dist);
96
+ return mix(u_colors[i], u_colors[i + 1], lDist);
97
+ }
98
+ }
99
+ return u_colors[LAST_STOP];
100
+ }
101
+
102
+ // Coverage of a disc centered at \`c\` with radius \`r\` at pixel \`p\` (with 1px AA)
103
+ float discCoverage(vec2 p, vec2 c, float r) {
104
+ return 1.0 - smoothstep(r - 1.0, r + 1.0, length(p - c));
105
+ }
106
+
107
+ void main() {
108
+ vec4 base = texture2D(u_texture, v_textureCoords) * v_color;
109
+
110
+ vec2 p = v_nodeCoords.xy * u_dimensions - u_center;
111
+ float dist = length(p);
112
+ float halfW = u_width * 0.5;
113
+
114
+ // Ring coverage: 1 inside the stroke band, 0 outside (with 1px AA on both edges)
115
+ float ringCoverage =
116
+ smoothstep(u_radius - halfW - 1.0, u_radius - halfW + 1.0, dist) *
117
+ (1.0 - smoothstep(u_radius + halfW - 1.0, u_radius + halfW + 1.0, dist));
118
+
119
+ // Angle along the arc, normalized to [0, 1) starting at u_startAngle
120
+ float ang = atan(p.y, p.x);
121
+ float t = mod((ang - u_startAngle) * u_direction, TWO_PI) / TWO_PI;
122
+
123
+ // Filled arc coverage (1 if in filled arc, else 0). When progress >= 1 the
124
+ // whole ring is filled regardless of \`t\` -- guards against the mod() seam.
125
+ float arcCoverage = u_progress >= 1.0 ? 1.0 : step(t, u_progress);
126
+ float fillCoverage = ringCoverage * arcCoverage;
127
+
128
+ #if CAP_ROUND
129
+ // Round caps: discs of radius halfW at the start and head of the arc
130
+ float a0 = u_startAngle;
131
+ float a1 = u_startAngle + u_direction * u_progress * TWO_PI;
132
+ vec2 cap0 = vec2(cos(a0), sin(a0)) * u_radius;
133
+ vec2 cap1 = vec2(cos(a1), sin(a1)) * u_radius;
134
+ float capMask = max(discCoverage(p, cap0, halfW), discCoverage(p, cap1, halfW));
135
+ // Caps only visible when there's something to cap (progress > 0 and < 1).
136
+ float capGate = step(0.0001, u_progress) * step(u_progress, 0.9999);
137
+ fillCoverage = max(fillCoverage, capMask * capGate);
138
+ #endif
139
+
140
+ // Sample gradient. Normalize \`t\` to the *filled* portion so the gradient
141
+ // spans the visible arc end-to-end regardless of progress.
142
+ float gradT = u_progress > 0.0 ? clamp(t / u_progress, 0.0, 1.0) : 0.0;
143
+ vec4 fillCol = getGradientColor(gradT);
144
+
145
+ // Composite: track under fill (if track enabled), both gated by ringCoverage
146
+ vec4 layer = vec4(0.0);
147
+ #if HAS_TRACK
148
+ float trackCoverage = ringCoverage * (1.0 - fillCoverage);
149
+ layer = u_trackColor * trackCoverage + fillCol * fillCoverage;
150
+ #else
151
+ layer = fillCol * fillCoverage;
152
+ #endif
153
+
154
+ // Composite layer over base. Output alpha = base.a + layer.a*(1-base.a)
155
+ // so the ring is visible even when the node's base color is fully transparent.
156
+ float la = clamp(layer.a, 0.0, 1.0);
157
+ vec3 blended = mix(base.rgb, layer.rgb, la);
158
+ float outA = base.a + la * (1.0 - base.a);
159
+ gl_FragColor = vec4(blended, outA);
160
+ }
161
+ `;
162
+ },
163
+ };