@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.
- package/dist/exports/canvas-shaders.d.ts +1 -0
- package/dist/exports/canvas-shaders.js +1 -0
- package/dist/exports/canvas-shaders.js.map +1 -1
- package/dist/exports/webgl-shaders.d.ts +1 -0
- package/dist/exports/webgl-shaders.js +1 -0
- package/dist/exports/webgl-shaders.js.map +1 -1
- package/dist/src/core/CoreNode.js +4 -2
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/CoreTextNode.js +9 -7
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/Stage.js +6 -0
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +11 -8
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +3 -2
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
- package/dist/src/core/shaders/canvas/RadialProgress.d.ts +10 -0
- package/dist/src/core/shaders/canvas/RadialProgress.js +87 -0
- package/dist/src/core/shaders/canvas/RadialProgress.js.map +1 -0
- package/dist/src/core/shaders/templates/RadialProgressTemplate.d.ts +61 -0
- package/dist/src/core/shaders/templates/RadialProgressTemplate.js +54 -0
- package/dist/src/core/shaders/templates/RadialProgressTemplate.js.map +1 -0
- package/dist/src/core/shaders/webgl/Border.js +6 -4
- package/dist/src/core/shaders/webgl/Border.js.map +1 -1
- package/dist/src/core/shaders/webgl/RadialProgress.d.ts +3 -0
- package/dist/src/core/shaders/webgl/RadialProgress.js +145 -0
- package/dist/src/core/shaders/webgl/RadialProgress.js.map +1 -0
- package/dist/src/core/shaders/webgl/RoundedWithBorder.js +6 -4
- package/dist/src/core/shaders/webgl/RoundedWithBorder.js.map +1 -1
- package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js +6 -4
- package/dist/src/core/shaders/webgl/RoundedWithBorderAndShadow.js.map +1 -1
- package/dist/src/core/text-rendering/CanvasTextRenderer.js +5 -2
- package/dist/src/core/text-rendering/CanvasTextRenderer.js.map +1 -1
- package/dist/src/core/text-rendering/SdfFontHandler.js +35 -0
- package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
- package/dist/src/core/text-rendering/SdfTextRenderer.js +21 -20
- package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
- package/dist/src/core/text-rendering/TextLayoutEngine.d.ts +7 -1
- package/dist/src/core/text-rendering/TextLayoutEngine.js +94 -10
- package/dist/src/core/text-rendering/TextLayoutEngine.js.map +1 -1
- package/dist/src/core/text-rendering/TextRenderer.d.ts +61 -10
- package/dist/src/main-api/Renderer.d.ts +23 -1
- package/dist/src/main-api/Renderer.js +6 -1
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/exports/canvas-shaders.ts +1 -0
- package/exports/webgl-shaders.ts +1 -0
- package/package.json +1 -1
- package/src/core/CoreNode.ts +6 -2
- package/src/core/CoreTextNode.ts +9 -6
- package/src/core/Stage.ts +7 -0
- package/src/core/renderers/webgl/WebGlCtxTexture.test.ts +94 -0
- package/src/core/renderers/webgl/WebGlRenderer.ts +14 -10
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +3 -2
- package/src/core/shaders/canvas/RadialProgress.ts +113 -0
- package/src/core/shaders/templates/RadialProgressTemplate.test.ts +125 -0
- package/src/core/shaders/templates/RadialProgressTemplate.ts +112 -0
- package/src/core/shaders/webgl/Border.ts +6 -4
- package/src/core/shaders/webgl/RadialProgress.ts +163 -0
- package/src/core/shaders/webgl/RoundedWithBorder.ts +6 -4
- package/src/core/shaders/webgl/RoundedWithBorderAndShadow.ts +6 -4
- package/src/core/text-rendering/CanvasTextRenderer.ts +5 -2
- package/src/core/text-rendering/SdfFontHandler.ts +37 -0
- package/src/core/text-rendering/SdfTextRenderer.ts +28 -22
- package/src/core/text-rendering/TextLayoutEngine.ts +99 -12
- package/src/core/text-rendering/TextRenderer.ts +62 -10
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
300
|
+
const t = textures[0];
|
|
301
|
+
if (t === undefined) {
|
|
301
302
|
return;
|
|
302
303
|
}
|
|
303
304
|
this.glw.activeTexture(0);
|
|
304
|
-
this.glw.bindTexture(
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
float
|
|
53
|
-
float
|
|
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
|
+
};
|