@zakkster/lite-gradient-studio 1.0.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/CHANGELOG.md +105 -0
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/llms.txt +173 -0
- package/package.json +81 -0
- package/src/bake.js +141 -0
- package/src/color-convert.js +233 -0
- package/src/css-emitters.js +86 -0
- package/src/exporters.js +310 -0
- package/src/gradient-parse.js +447 -0
- package/src/index.d.ts +380 -0
- package/src/index.js +38 -0
- package/src/mesh-css.js +113 -0
- package/src/mesh.js +673 -0
- package/src/palette-extract.js +216 -0
package/src/mesh.js
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshGradient — NxM control grid of OKLCH stops with bilinear interpolation.
|
|
3
|
+
*
|
|
4
|
+
* Storage: row-major array of plain { l, c, h } stop objects.
|
|
5
|
+
* stops[row * cols + col] → the (col, row) control point.
|
|
6
|
+
*
|
|
7
|
+
* Sampling: bilinear in OKLCH space, using lite-color's lerpOklchTo which
|
|
8
|
+
* takes the hue-shortest path. Hue wrap (e.g. 350° ↔ 10°) interpolates
|
|
9
|
+
* through 360°/0° correctly without special handling here.
|
|
10
|
+
*
|
|
11
|
+
* Rasterization: per-pixel sampleAt + pack → Uint32Array, byte-order
|
|
12
|
+
* compatible with `new Uint8ClampedArray(out.buffer)` for ImageData blits.
|
|
13
|
+
* This is the baseline kernel — direct, O(width * height) sample+pack.
|
|
14
|
+
* Worth measuring before optimizing (Web Worker, 2D LUT, GPU paths all
|
|
15
|
+
* available later if drag perf demands it).
|
|
16
|
+
*
|
|
17
|
+
* v0.0.2 scope: regular NxM grid only. Arbitrary 2D control points via
|
|
18
|
+
* Delaunay triangulation (lite-delaunay) is a separate kernel for later.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { lerpOklchTo } from '@zakkster/lite-color';
|
|
22
|
+
import { packOklchSingle } from './bake.js';
|
|
23
|
+
|
|
24
|
+
/** Normalize a hue angle to [0, 360). Handles negative and >=360 inputs. */
|
|
25
|
+
function normHue(h) {
|
|
26
|
+
let n = h % 360;
|
|
27
|
+
if (n < 0) n += 360;
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Catmull-Rom 1D blending — 4 control points + parameter t in [0, 1]
|
|
33
|
+
* between p1 and p2. p0 and p3 are the neighbours that supply tangent
|
|
34
|
+
* info. Standard form: tangent at p1 is (p2 - p0)/2, at p2 is (p3 - p1)/2.
|
|
35
|
+
*
|
|
36
|
+
* Output is exact at p1 (t=0) and p2 (t=1), C¹ continuous across
|
|
37
|
+
* boundaries when neighbouring patches share the same end-point tangent
|
|
38
|
+
* (which they do here — adjacent patches read shared corner colors).
|
|
39
|
+
*
|
|
40
|
+
* Can overshoot the input range: for an L-channel input [0.3, 0.9, 0.9, 0.3]
|
|
41
|
+
* the cubic may peak above 0.9 in the middle. Callers clamp final
|
|
42
|
+
* channel outputs to valid ranges.
|
|
43
|
+
*/
|
|
44
|
+
function catmullRom1D(p0, p1, p2, p3, t) {
|
|
45
|
+
const t2 = t * t;
|
|
46
|
+
const t3 = t2 * t;
|
|
47
|
+
return 0.5 * (
|
|
48
|
+
2 * p1 +
|
|
49
|
+
(-p0 + p2) * t +
|
|
50
|
+
(2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +
|
|
51
|
+
(-p0 + 3 * p1 - 3 * p2 + p3) * t3
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Align 4 hue values onto a consistent monotone-friendly axis so
|
|
57
|
+
* Catmull-Rom doesn't take the long way around the colour wheel.
|
|
58
|
+
*
|
|
59
|
+
* Strategy: anchor on h1, then walk outward. Each neighbour is
|
|
60
|
+
* shifted by ±360° to land within 180° of the preceding aligned hue.
|
|
61
|
+
* After this, plain catmullRom1D on the aligned values produces the
|
|
62
|
+
* shortest-path interpolation; mod-360 the final output.
|
|
63
|
+
*
|
|
64
|
+
* Returns into the shared `_hueAlign` scratch (no allocation per call).
|
|
65
|
+
* Concurrent calls would clobber, but sampleAt's tight loop reads the
|
|
66
|
+
* result immediately so this is safe.
|
|
67
|
+
*/
|
|
68
|
+
const _hueAlign = [0, 0, 0, 0];
|
|
69
|
+
function alignHues4(h0, h1, h2, h3) {
|
|
70
|
+
h0 = normHue(h0);
|
|
71
|
+
h1 = normHue(h1);
|
|
72
|
+
h2 = normHue(h2);
|
|
73
|
+
h3 = normHue(h3);
|
|
74
|
+
let d = h2 - h1;
|
|
75
|
+
if (d > 180) h2 -= 360;
|
|
76
|
+
else if (d < -180) h2 += 360;
|
|
77
|
+
d = h0 - h1;
|
|
78
|
+
if (d > 180) h0 -= 360;
|
|
79
|
+
else if (d < -180) h0 += 360;
|
|
80
|
+
d = h3 - h2;
|
|
81
|
+
if (d > 180) h3 -= 360;
|
|
82
|
+
else if (d < -180) h3 += 360;
|
|
83
|
+
_hueAlign[0] = h0; _hueAlign[1] = h1; _hueAlign[2] = h2; _hueAlign[3] = h3;
|
|
84
|
+
return _hueAlign;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Clamp final channel outputs after Catmull-Rom — overshoot can push
|
|
88
|
+
* L or A outside [0, 1] and C outside [0, 0.5]. Hue is wrapped, not
|
|
89
|
+
* clamped. */
|
|
90
|
+
function clampChannels(out) {
|
|
91
|
+
if (out.l < 0) out.l = 0; else if (out.l > 1) out.l = 1;
|
|
92
|
+
if (out.c < 0) out.c = 0; else if (out.c > 0.5) out.c = 0.5;
|
|
93
|
+
if (out.a < 0) out.a = 0; else if (out.a > 1) out.a = 1;
|
|
94
|
+
out.h = normHue(out.h);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Resolve rasterizer opts to a canonical interpolation mode string.
|
|
98
|
+
* Supports the new `opts.interpolation` AND the legacy `opts.smooth`
|
|
99
|
+
* boolean from v0.0.23. Unknown strings fall back to 'bilinear'. */
|
|
100
|
+
function resolveInterpMode(opts) {
|
|
101
|
+
if (!opts) return 'bilinear';
|
|
102
|
+
if (opts.interpolation === 'cubic') return 'cubic';
|
|
103
|
+
if (opts.interpolation === 'smooth') return 'smooth';
|
|
104
|
+
if (opts.interpolation === 'bilinear') return 'bilinear';
|
|
105
|
+
if (opts.smooth === true) return 'smooth';
|
|
106
|
+
return 'bilinear';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Procedural default color for a control point at (col, row) of a cols×rows
|
|
111
|
+
* mesh. Produces a Stripe-style aurora: blue at one corner sweeping through
|
|
112
|
+
* magenta to warm pink at the other, with chroma peaking in the middle and
|
|
113
|
+
* L gently darkening top-to-bottom. Smooth in all axes — no two adjacent
|
|
114
|
+
* cells are identical, no tiling artifacts at any mesh size.
|
|
115
|
+
*
|
|
116
|
+
* Pure function. Same input always returns the same output.
|
|
117
|
+
*
|
|
118
|
+
* @param {number} col 0..cols-1
|
|
119
|
+
* @param {number} row 0..rows-1
|
|
120
|
+
* @param {number} cols >= 2
|
|
121
|
+
* @param {number} rows >= 2
|
|
122
|
+
* @returns {{l:number,c:number,h:number}} fresh OKLCH triplet
|
|
123
|
+
*/
|
|
124
|
+
export function defaultMeshColor(col, row, cols, rows) {
|
|
125
|
+
const cT = cols === 1 ? 0.5 : col / (cols - 1);
|
|
126
|
+
const rT = rows === 1 ? 0.5 : row / (rows - 1);
|
|
127
|
+
|
|
128
|
+
// L: lighter at top, darker at bottom — gentle so colors stay vivid.
|
|
129
|
+
const l = 0.70 - 0.30 * rT;
|
|
130
|
+
|
|
131
|
+
// C: tent peak at (0.5, 0.5); edges keep some chroma so corners
|
|
132
|
+
// aren't flat gray. Range ≈ 0.15 (corners) to 0.25 (center).
|
|
133
|
+
const cDist = 1 - Math.abs(cT * 2 - 1);
|
|
134
|
+
const rDist = 1 - Math.abs(rT * 2 - 1);
|
|
135
|
+
const c = 0.15 + 0.10 * cDist * rDist;
|
|
136
|
+
|
|
137
|
+
// H: sweep across columns (deep blue → magenta → warm) + small
|
|
138
|
+
// diagonal drift by row. Wrap into [0, 360).
|
|
139
|
+
const h = normHue(240 + 120 * cT + 30 * rT);
|
|
140
|
+
|
|
141
|
+
return { l, c, h };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class MeshGradient {
|
|
145
|
+
/**
|
|
146
|
+
* @param {number} cols Number of columns (>= 2).
|
|
147
|
+
* @param {number} rows Number of rows (>= 2).
|
|
148
|
+
* @param {Array<{l:number,c:number,h:number}>} [stops]
|
|
149
|
+
* Optional row-major array of cols*rows control points. If omitted,
|
|
150
|
+
* a sensible default mesh is generated (currently only supported
|
|
151
|
+
* for cols=rows=3; other sizes get a tiled clone of DEFAULT_3x3).
|
|
152
|
+
*/
|
|
153
|
+
constructor(cols, rows, stops) {
|
|
154
|
+
if (!Number.isInteger(cols) || cols < 2) {
|
|
155
|
+
throw new Error('MeshGradient: cols must be an integer >= 2');
|
|
156
|
+
}
|
|
157
|
+
if (!Number.isInteger(rows) || rows < 2) {
|
|
158
|
+
throw new Error('MeshGradient: rows must be an integer >= 2');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.cols = cols;
|
|
162
|
+
this.rows = rows;
|
|
163
|
+
const total = cols * rows;
|
|
164
|
+
|
|
165
|
+
if (stops) {
|
|
166
|
+
if (stops.length !== total) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`MeshGradient: stops length (${stops.length}) does not match cols*rows (${total})`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
// Defensive copy — caller-provided objects get rewritten into
|
|
172
|
+
// our shape so external mutation can't corrupt the mesh.
|
|
173
|
+
// Default positions = regular grid. Caller stops may provide
|
|
174
|
+
// x/y to override; missing values default per (col, row).
|
|
175
|
+
// Alpha defaults to 1 when not specified; the field is always
|
|
176
|
+
// present on the internal stops to keep sampleAt's bilinear
|
|
177
|
+
// path branch-free.
|
|
178
|
+
this.stops = new Array(total);
|
|
179
|
+
for (let i = 0; i < total; i++) {
|
|
180
|
+
const s = stops[i];
|
|
181
|
+
const col = i % cols;
|
|
182
|
+
const row = (i / cols) | 0;
|
|
183
|
+
this.stops[i] = {
|
|
184
|
+
l: s.l, c: s.c, h: s.h,
|
|
185
|
+
a: s.a === undefined ? 1 : s.a,
|
|
186
|
+
x: s.x !== undefined ? s.x : col / (cols - 1),
|
|
187
|
+
y: s.y !== undefined ? s.y : row / (rows - 1),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// Procedural default: smooth aurora at any size, no tiling.
|
|
192
|
+
this.stops = new Array(total);
|
|
193
|
+
for (let i = 0; i < total; i++) {
|
|
194
|
+
const col = i % cols;
|
|
195
|
+
const row = (i / cols) | 0;
|
|
196
|
+
const src = defaultMeshColor(col, row, cols, rows);
|
|
197
|
+
this.stops[i] = {
|
|
198
|
+
l: src.l, c: src.c, h: src.h, a: 1,
|
|
199
|
+
x: col / (cols - 1),
|
|
200
|
+
y: row / (rows - 1),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Scratch slots for the two intermediate edge lerps in bilinear
|
|
206
|
+
// sampling. Allocated once, reused per sample → zero-GC.
|
|
207
|
+
this._scratchTop = { l: 0, c: 0, h: 0, a: 1 };
|
|
208
|
+
this._scratchBot = { l: 0, c: 0, h: 0, a: 1 };
|
|
209
|
+
|
|
210
|
+
// Catmull-Rom 2D needs 4 row results before the column blend.
|
|
211
|
+
// Pre-allocated as one fixed array; the sample loop reads them
|
|
212
|
+
// immediately so reuse across calls is safe.
|
|
213
|
+
this._scratchCubicRows = [
|
|
214
|
+
{ l: 0, c: 0, h: 0, a: 1 },
|
|
215
|
+
{ l: 0, c: 0, h: 0, a: 1 },
|
|
216
|
+
{ l: 0, c: 0, h: 0, a: 1 },
|
|
217
|
+
{ l: 0, c: 0, h: 0, a: 1 },
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Get a control point by (col, row) into a caller-owned output.
|
|
223
|
+
* Zero-GC.
|
|
224
|
+
*
|
|
225
|
+
* @param {number} col
|
|
226
|
+
* @param {number} row
|
|
227
|
+
* @param {{l:number,c:number,h:number}} out
|
|
228
|
+
* @returns {{l:number,c:number,h:number}} same `out`
|
|
229
|
+
*/
|
|
230
|
+
getPoint(col, row, out) {
|
|
231
|
+
const s = this.stops[row * this.cols + col];
|
|
232
|
+
out.l = s.l; out.c = s.c; out.h = s.h;
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Set a control point by (col, row). Mutates the underlying stop in
|
|
238
|
+
* place — safe to call inside a reactive effect's batch.
|
|
239
|
+
*/
|
|
240
|
+
setPoint(col, row, l, c, h) {
|
|
241
|
+
const s = this.stops[row * this.cols + col];
|
|
242
|
+
s.l = l; s.c = c; s.h = h;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Set the (x, y) position of a control point in normalized [0, 1]²
|
|
247
|
+
* canvas space. Positions are honored by `rasterizeDeformedTo`;
|
|
248
|
+
* `rasterizeTo` and `sampleAt` ignore positions (they assume the
|
|
249
|
+
* regular-grid UV mapping).
|
|
250
|
+
*/
|
|
251
|
+
setPointPosition(col, row, x, y) {
|
|
252
|
+
const s = this.stops[row * this.cols + col];
|
|
253
|
+
s.x = x;
|
|
254
|
+
s.y = y;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Read the (x, y) position of a control point into a caller-owned
|
|
259
|
+
* `{ x, y }` output. Zero-GC.
|
|
260
|
+
*/
|
|
261
|
+
getPointPosition(col, row, out) {
|
|
262
|
+
const s = this.stops[row * this.cols + col];
|
|
263
|
+
out.x = s.x;
|
|
264
|
+
out.y = s.y;
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Reset all control-point positions to the regular grid (the default
|
|
270
|
+
* after construction). Colors are untouched.
|
|
271
|
+
*/
|
|
272
|
+
resetPositions() {
|
|
273
|
+
const { cols, rows, stops } = this;
|
|
274
|
+
for (let r = 0; r < rows; r++) {
|
|
275
|
+
for (let c = 0; c < cols; c++) {
|
|
276
|
+
const s = stops[r * cols + c];
|
|
277
|
+
s.x = c / (cols - 1);
|
|
278
|
+
s.y = r / (rows - 1);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Sample the gradient at parametric (u, v) in [0, 1]^2 into `out`.
|
|
285
|
+
* Zero-GC: writes into the caller-owned `out` object to avoid the
|
|
286
|
+
* per-pixel allocation a return-by-value version would force.
|
|
287
|
+
* Bilinear in OKLCH, hue-shortest-path via `lerpOklchTo`.
|
|
288
|
+
*
|
|
289
|
+
* Hue normalization matters: `lerpOklchTo` picks the shortest hue
|
|
290
|
+
* path but does NOT bound its output to [0, 360). At extreme corners
|
|
291
|
+
* or after wrap-crossing lerps, the intermediate hue can land at 390
|
|
292
|
+
* or -30. Left unnormalized, that propagates into the second lerp
|
|
293
|
+
* and adds another 360 to the final hue. We normalize after each
|
|
294
|
+
* stage.
|
|
295
|
+
*
|
|
296
|
+
* The optional fourth parameter selects interpolation. Accepts:
|
|
297
|
+
* - false / undefined -> 'bilinear' (original, C^0)
|
|
298
|
+
* - true -> 'smooth' (smoothstep on cu/cv, C^1 at seams)
|
|
299
|
+
* - 'cubic' -> Catmull-Rom 2D (C^1 everywhere; ~4x cost)
|
|
300
|
+
* - any of the strings above explicitly
|
|
301
|
+
*
|
|
302
|
+
* @param {number} u
|
|
303
|
+
* @param {number} v
|
|
304
|
+
* @param {{l:number,c:number,h:number,a?:number}} out
|
|
305
|
+
* @param {boolean|string} [modeOrSmooth=false]
|
|
306
|
+
* @returns {{l:number,c:number,h:number,a:number}} same `out`
|
|
307
|
+
*/
|
|
308
|
+
sampleAt(u, v, out, modeOrSmooth = false) {
|
|
309
|
+
// Normalize the legacy boolean to the string form. Hot path —
|
|
310
|
+
// keep the conditional tight.
|
|
311
|
+
const mode = modeOrSmooth === true ? 'smooth'
|
|
312
|
+
: modeOrSmooth === false ? 'bilinear'
|
|
313
|
+
: modeOrSmooth;
|
|
314
|
+
if (mode === 'cubic') return this._sampleAtCubic(u, v, out);
|
|
315
|
+
|
|
316
|
+
// Clamp to unit square.
|
|
317
|
+
if (u < 0) u = 0; else if (u > 1) u = 1;
|
|
318
|
+
if (v < 0) v = 0; else if (v > 1) v = 1;
|
|
319
|
+
|
|
320
|
+
const cols = this.cols;
|
|
321
|
+
const rows = this.rows;
|
|
322
|
+
const stops = this.stops;
|
|
323
|
+
|
|
324
|
+
// Map to grid coords. Last cell index is (cols-2, rows-2) — the
|
|
325
|
+
// cell whose right/bottom edge is the mesh boundary.
|
|
326
|
+
const fu = u * (cols - 1);
|
|
327
|
+
const fv = v * (rows - 1);
|
|
328
|
+
let col = fu | 0;
|
|
329
|
+
let row = fv | 0;
|
|
330
|
+
let cu = fu - col;
|
|
331
|
+
let cv = fv - row;
|
|
332
|
+
if (col >= cols - 1) { col = cols - 2; cu = 1; }
|
|
333
|
+
if (row >= rows - 1) { row = rows - 2; cv = 1; }
|
|
334
|
+
|
|
335
|
+
if (mode === 'smooth') {
|
|
336
|
+
// smoothstep eases the (cu, cv) → blend mapping; corner colors
|
|
337
|
+
// (cu/cv = 0 or 1) are preserved exactly.
|
|
338
|
+
cu = cu * cu * (3 - 2 * cu);
|
|
339
|
+
cv = cv * cv * (3 - 2 * cv);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const base = row * cols + col;
|
|
343
|
+
const c00 = stops[base];
|
|
344
|
+
const c10 = stops[base + 1];
|
|
345
|
+
const c01 = stops[base + cols];
|
|
346
|
+
const c11 = stops[base + cols + 1];
|
|
347
|
+
|
|
348
|
+
// Top edge then bottom edge into scratch, then v-lerp into out.
|
|
349
|
+
// Normalize each intermediate hue so subsequent lerps see a value
|
|
350
|
+
// in [0, 360) and pick the right shortest path. Alpha lerps
|
|
351
|
+
// linearly alongside — lerpOklchTo only touches L/C/H so we do
|
|
352
|
+
// it inline.
|
|
353
|
+
lerpOklchTo(c00, c10, cu, this._scratchTop);
|
|
354
|
+
this._scratchTop.h = normHue(this._scratchTop.h);
|
|
355
|
+
this._scratchTop.a = c00.a + (c10.a - c00.a) * cu;
|
|
356
|
+
lerpOklchTo(c01, c11, cu, this._scratchBot);
|
|
357
|
+
this._scratchBot.h = normHue(this._scratchBot.h);
|
|
358
|
+
this._scratchBot.a = c01.a + (c11.a - c01.a) * cu;
|
|
359
|
+
lerpOklchTo(this._scratchTop, this._scratchBot, cv, out);
|
|
360
|
+
out.h = normHue(out.h);
|
|
361
|
+
out.a = this._scratchTop.a + (this._scratchBot.a - this._scratchTop.a) * cv;
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Catmull-Rom 2D sample. Internal — entry is via sampleAt's `mode`
|
|
367
|
+
* parameter. Reads a 4×4 neighbourhood around the patch containing
|
|
368
|
+
* (u, v); edge patches clamp out-of-grid indices to the nearest
|
|
369
|
+
* valid stop (graceful degradation toward bilinear at edges, smooth
|
|
370
|
+
* elsewhere).
|
|
371
|
+
*
|
|
372
|
+
* Channels L, C, A are scalar-interpolated; H aligns first to
|
|
373
|
+
* avoid 0/360 wrap-around producing the wrong path. Outputs are
|
|
374
|
+
* clamped to valid ranges since Catmull-Rom can overshoot.
|
|
375
|
+
*/
|
|
376
|
+
_sampleAtCubic(u, v, out) {
|
|
377
|
+
if (u < 0) u = 0; else if (u > 1) u = 1;
|
|
378
|
+
if (v < 0) v = 0; else if (v > 1) v = 1;
|
|
379
|
+
|
|
380
|
+
const cols = this.cols;
|
|
381
|
+
const rows = this.rows;
|
|
382
|
+
|
|
383
|
+
const fu = u * (cols - 1);
|
|
384
|
+
const fv = v * (rows - 1);
|
|
385
|
+
let col = fu | 0;
|
|
386
|
+
let row = fv | 0;
|
|
387
|
+
let cu = fu - col;
|
|
388
|
+
let cv = fv - row;
|
|
389
|
+
if (col >= cols - 1) { col = cols - 2; cu = 1; }
|
|
390
|
+
if (row >= rows - 1) { row = rows - 2; cv = 1; }
|
|
391
|
+
|
|
392
|
+
// 4×4 neighbour columns + rows, clamped to grid bounds. At a
|
|
393
|
+
// boundary patch the spline degrades toward bilinear (duplicate
|
|
394
|
+
// endpoints give a flat tangent there), which is the right
|
|
395
|
+
// behaviour — there's no extrapolation data to inform a
|
|
396
|
+
// sharper curve outside the grid.
|
|
397
|
+
const im1 = col - 1 < 0 ? 0 : col - 1;
|
|
398
|
+
const i0 = col;
|
|
399
|
+
const i1 = col + 1;
|
|
400
|
+
const i2 = col + 2 >= cols ? cols - 1 : col + 2;
|
|
401
|
+
|
|
402
|
+
const jm1 = row - 1 < 0 ? 0 : row - 1;
|
|
403
|
+
const j0 = row;
|
|
404
|
+
const j1 = row + 1;
|
|
405
|
+
const j2 = row + 2 >= rows ? rows - 1 : row + 2;
|
|
406
|
+
|
|
407
|
+
// Step 1: blend each of the 4 rows along x at parameter cu.
|
|
408
|
+
this._cubicRow(jm1, im1, i0, i1, i2, cu, this._scratchCubicRows[0]);
|
|
409
|
+
this._cubicRow(j0, im1, i0, i1, i2, cu, this._scratchCubicRows[1]);
|
|
410
|
+
this._cubicRow(j1, im1, i0, i1, i2, cu, this._scratchCubicRows[2]);
|
|
411
|
+
this._cubicRow(j2, im1, i0, i1, i2, cu, this._scratchCubicRows[3]);
|
|
412
|
+
|
|
413
|
+
// Step 2: blend the 4 row results along y at parameter cv.
|
|
414
|
+
const r0 = this._scratchCubicRows[0];
|
|
415
|
+
const r1 = this._scratchCubicRows[1];
|
|
416
|
+
const r2 = this._scratchCubicRows[2];
|
|
417
|
+
const r3 = this._scratchCubicRows[3];
|
|
418
|
+
const ah = alignHues4(r0.h, r1.h, r2.h, r3.h);
|
|
419
|
+
out.l = catmullRom1D(r0.l, r1.l, r2.l, r3.l, cv);
|
|
420
|
+
out.c = catmullRom1D(r0.c, r1.c, r2.c, r3.c, cv);
|
|
421
|
+
out.h = catmullRom1D(ah[0], ah[1], ah[2], ah[3], cv);
|
|
422
|
+
out.a = catmullRom1D(r0.a, r1.a, r2.a, r3.a, cv);
|
|
423
|
+
clampChannels(out);
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Blend one row at columns (im1, i0, i1, i2) with parameter t,
|
|
428
|
+
* writing the result to `dst`. Helper for _sampleAtCubic. */
|
|
429
|
+
_cubicRow(jRow, im1, i0, i1, i2, t, dst) {
|
|
430
|
+
const cols = this.cols;
|
|
431
|
+
const stops = this.stops;
|
|
432
|
+
const s0 = stops[jRow * cols + im1];
|
|
433
|
+
const s1 = stops[jRow * cols + i0];
|
|
434
|
+
const s2 = stops[jRow * cols + i1];
|
|
435
|
+
const s3 = stops[jRow * cols + i2];
|
|
436
|
+
const ah = alignHues4(s0.h, s1.h, s2.h, s3.h);
|
|
437
|
+
dst.l = catmullRom1D(s0.l, s1.l, s2.l, s3.l, t);
|
|
438
|
+
dst.c = catmullRom1D(s0.c, s1.c, s2.c, s3.c, t);
|
|
439
|
+
dst.h = catmullRom1D(ah[0], ah[1], ah[2], ah[3], t);
|
|
440
|
+
dst.a = catmullRom1D(s0.a, s1.a, s2.a, s3.a, t);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Rasterize the mesh into a packed-RGBA Uint32Array of width*height
|
|
445
|
+
* pixels (little-endian byte order — aliasable as Uint8ClampedArray
|
|
446
|
+
* for ImageData blits).
|
|
447
|
+
*
|
|
448
|
+
* @param {Uint32Array} out Caller-owned buffer of length >= width*height.
|
|
449
|
+
* @param {number} width
|
|
450
|
+
* @param {number} height
|
|
451
|
+
* @param {object} [opts]
|
|
452
|
+
* @param {'bilinear'|'smooth'|'cubic'} [opts.interpolation='bilinear']
|
|
453
|
+
* - 'bilinear' (default): C⁰, fastest.
|
|
454
|
+
* - 'smooth': smoothstep on cu/cv, C¹ at seams, ~2 mults overhead.
|
|
455
|
+
* - 'cubic': Catmull-Rom 2D, C¹ everywhere, ~4× slower.
|
|
456
|
+
* @param {boolean} [opts.smooth] Legacy boolean: equivalent to
|
|
457
|
+
* interpolation='smooth' when true.
|
|
458
|
+
* Preserved for v0.0.23 callers.
|
|
459
|
+
* @returns {Uint32Array} Same `out`.
|
|
460
|
+
*/
|
|
461
|
+
rasterizeTo(out, width, height, opts) {
|
|
462
|
+
if (!(out instanceof Uint32Array) || out.length < width * height) {
|
|
463
|
+
throw new Error('MeshGradient.rasterizeTo: out must be a Uint32Array with length >= width*height');
|
|
464
|
+
}
|
|
465
|
+
const mode = resolveInterpMode(opts);
|
|
466
|
+
const tmp = { l: 0, c: 0, h: 0, a: 1 };
|
|
467
|
+
const wInv = 1 / (width - 1);
|
|
468
|
+
const hInv = 1 / (height - 1);
|
|
469
|
+
let i = 0;
|
|
470
|
+
for (let y = 0; y < height; y++) {
|
|
471
|
+
const v = y * hInv;
|
|
472
|
+
for (let x = 0; x < width; x++) {
|
|
473
|
+
const u = x * wInv;
|
|
474
|
+
this.sampleAt(u, v, tmp, mode);
|
|
475
|
+
out[i++] = packOklchSingle(tmp.l, tmp.c, tmp.h, tmp.a);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Rasterize honoring control-point positions (deformable mesh).
|
|
483
|
+
*
|
|
484
|
+
* For each grid quad, walk its pixel bounding box, solve the inverse
|
|
485
|
+
* bilinear (Newton, 8-iter cap) to recover the local (u, v) within
|
|
486
|
+
* that quad, blend the four corner colors with that local (u, v),
|
|
487
|
+
* and pack. Pixels outside any quad are left untouched.
|
|
488
|
+
*
|
|
489
|
+
* Newton starts at (0.5, 0.5); converges in ~3-5 iters for
|
|
490
|
+
* well-behaved (non-folded) quads. Degenerate / self-intersecting
|
|
491
|
+
* quads will produce non-convergent pixels — they get skipped, which
|
|
492
|
+
* shows as holes in the preview. Treat that as a visual cue to the
|
|
493
|
+
* user that the mesh is broken rather than silently rendering
|
|
494
|
+
* garbage.
|
|
495
|
+
*
|
|
496
|
+
* NOTE: this does NOT clear `out` first. Callers that need a clean
|
|
497
|
+
* canvas (e.g. a fresh frame in an animation loop) must zero the
|
|
498
|
+
* buffer themselves, or fill with a background color via a separate
|
|
499
|
+
* pass before this.
|
|
500
|
+
*
|
|
501
|
+
* @param {Uint32Array} out
|
|
502
|
+
* @param {number} width
|
|
503
|
+
* @param {number} height
|
|
504
|
+
* @param {object} [opts]
|
|
505
|
+
* @param {'bilinear'|'smooth'|'cubic'} [opts.interpolation='bilinear']
|
|
506
|
+
* - 'bilinear' (default): standard forward bilinear blend.
|
|
507
|
+
* - 'smooth': smoothstep on recovered (u, v) before blend.
|
|
508
|
+
* - 'cubic': Catmull-Rom 2D over the 4×4 neighbourhood of
|
|
509
|
+
* the patch containing the pixel (~4× slower).
|
|
510
|
+
* @param {boolean} [opts.smooth] Legacy boolean; true → 'smooth'.
|
|
511
|
+
* @returns {Uint32Array}
|
|
512
|
+
*/
|
|
513
|
+
rasterizeDeformedTo(out, width, height, opts) {
|
|
514
|
+
if (!(out instanceof Uint32Array) || out.length < width * height) {
|
|
515
|
+
throw new Error('MeshGradient.rasterizeDeformedTo: out must be a Uint32Array with length >= width*height');
|
|
516
|
+
}
|
|
517
|
+
const mode = resolveInterpMode(opts);
|
|
518
|
+
const smooth = mode === 'smooth';
|
|
519
|
+
const cubic = mode === 'cubic';
|
|
520
|
+
const cols = this.cols;
|
|
521
|
+
const rows = this.rows;
|
|
522
|
+
const stops = this.stops;
|
|
523
|
+
const Wm1 = width - 1;
|
|
524
|
+
const Hm1 = height - 1;
|
|
525
|
+
|
|
526
|
+
// Reusable scratch for the per-pixel color blend.
|
|
527
|
+
const top = this._scratchTop;
|
|
528
|
+
const bot = this._scratchBot;
|
|
529
|
+
const px = { l: 0, c: 0, h: 0, a: 1 };
|
|
530
|
+
|
|
531
|
+
for (let qr = 0; qr < rows - 1; qr++) {
|
|
532
|
+
for (let qc = 0; qc < cols - 1; qc++) {
|
|
533
|
+
const iTL = qr * cols + qc;
|
|
534
|
+
const iTR = iTL + 1;
|
|
535
|
+
const iBL = (qr + 1) * cols + qc;
|
|
536
|
+
const iBR = iBL + 1;
|
|
537
|
+
|
|
538
|
+
const sTL = stops[iTL], sTR = stops[iTR];
|
|
539
|
+
const sBL = stops[iBL], sBR = stops[iBR];
|
|
540
|
+
|
|
541
|
+
// Corner positions in pixel space.
|
|
542
|
+
const x00 = sTL.x * Wm1, y00 = sTL.y * Hm1;
|
|
543
|
+
const x10 = sTR.x * Wm1, y10 = sTR.y * Hm1;
|
|
544
|
+
const x01 = sBL.x * Wm1, y01 = sBL.y * Hm1;
|
|
545
|
+
const x11 = sBR.x * Wm1, y11 = sBR.y * Hm1;
|
|
546
|
+
|
|
547
|
+
// Bilinear parameterization:
|
|
548
|
+
// P(u,v) = a + u*E + v*F + u*v*G
|
|
549
|
+
// where a = P00, E = P10-P00, F = P01-P00,
|
|
550
|
+
// G = P00 - P10 - P01 + P11.
|
|
551
|
+
const ax = x00, ay = y00;
|
|
552
|
+
const ex = x10 - x00, ey = y10 - y00;
|
|
553
|
+
const fx = x01 - x00, fy = y01 - y00;
|
|
554
|
+
const gx = x00 - x10 - x01 + x11, gy = y00 - y10 - y01 + y11;
|
|
555
|
+
|
|
556
|
+
// Quad bbox clipped to canvas.
|
|
557
|
+
let minX = Math.min(x00, x10, x01, x11) | 0;
|
|
558
|
+
let maxX = Math.ceil(Math.max(x00, x10, x01, x11));
|
|
559
|
+
let minY = Math.min(y00, y10, y01, y11) | 0;
|
|
560
|
+
let maxY = Math.ceil(Math.max(y00, y10, y01, y11));
|
|
561
|
+
if (minX < 0) minX = 0;
|
|
562
|
+
if (minY < 0) minY = 0;
|
|
563
|
+
if (maxX > Wm1) maxX = Wm1;
|
|
564
|
+
if (maxY > Hm1) maxY = Hm1;
|
|
565
|
+
|
|
566
|
+
for (let py = minY; py <= maxY; py++) {
|
|
567
|
+
for (let pX = minX; pX <= maxX; pX++) {
|
|
568
|
+
const Hx = pX - ax;
|
|
569
|
+
const Hy = py - ay;
|
|
570
|
+
|
|
571
|
+
// Newton solve: R(u,v) = u*E + v*F + u*v*G - H = 0
|
|
572
|
+
let u = 0.5, v = 0.5;
|
|
573
|
+
let converged = false;
|
|
574
|
+
for (let iter = 0; iter < 8; iter++) {
|
|
575
|
+
const Rx = u * ex + v * fx + u * v * gx - Hx;
|
|
576
|
+
const Ry = u * ey + v * fy + u * v * gy - Hy;
|
|
577
|
+
const Jux = ex + v * gx;
|
|
578
|
+
const Juy = ey + v * gy;
|
|
579
|
+
const Jvx = fx + u * gx;
|
|
580
|
+
const Jvy = fy + u * gy;
|
|
581
|
+
const det = Jux * Jvy - Juy * Jvx;
|
|
582
|
+
if (Math.abs(det) < 1e-12) break;
|
|
583
|
+
const invDet = 1 / det;
|
|
584
|
+
const du = ( Jvy * Rx - Jvx * Ry) * invDet;
|
|
585
|
+
const dv = (-Juy * Rx + Jux * Ry) * invDet;
|
|
586
|
+
u -= du;
|
|
587
|
+
v -= dv;
|
|
588
|
+
if (Math.abs(du) + Math.abs(dv) < 1e-5) {
|
|
589
|
+
converged = true;
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (!converged) continue;
|
|
594
|
+
|
|
595
|
+
// Strict in-quad test. A small epsilon would help
|
|
596
|
+
// seal cracks across shared edges of adjacent quads
|
|
597
|
+
// but also leaks into neighbors; the current write
|
|
598
|
+
// is idempotent for shared edges so it doesn't
|
|
599
|
+
// matter (both quads compute the same color there).
|
|
600
|
+
if (u < 0 || u > 1 || v < 0 || v > 1) continue;
|
|
601
|
+
|
|
602
|
+
if (cubic) {
|
|
603
|
+
// Catmull-Rom 2D over the 4×4 neighbourhood
|
|
604
|
+
// around quad (qc, qr). Indices clamp to the
|
|
605
|
+
// grid bounds at edges → degrades toward
|
|
606
|
+
// bilinear at the very border, smooth elsewhere.
|
|
607
|
+
const im1 = qc - 1 < 0 ? 0 : qc - 1;
|
|
608
|
+
const i0 = qc;
|
|
609
|
+
const i1 = qc + 1;
|
|
610
|
+
const i2 = qc + 2 >= cols ? cols - 1 : qc + 2;
|
|
611
|
+
const jm1 = qr - 1 < 0 ? 0 : qr - 1;
|
|
612
|
+
const j0 = qr;
|
|
613
|
+
const j1 = qr + 1;
|
|
614
|
+
const j2 = qr + 2 >= rows ? rows - 1 : qr + 2;
|
|
615
|
+
this._cubicRow(jm1, im1, i0, i1, i2, u, this._scratchCubicRows[0]);
|
|
616
|
+
this._cubicRow(j0, im1, i0, i1, i2, u, this._scratchCubicRows[1]);
|
|
617
|
+
this._cubicRow(j1, im1, i0, i1, i2, u, this._scratchCubicRows[2]);
|
|
618
|
+
this._cubicRow(j2, im1, i0, i1, i2, u, this._scratchCubicRows[3]);
|
|
619
|
+
const r0 = this._scratchCubicRows[0];
|
|
620
|
+
const r1 = this._scratchCubicRows[1];
|
|
621
|
+
const r2 = this._scratchCubicRows[2];
|
|
622
|
+
const r3 = this._scratchCubicRows[3];
|
|
623
|
+
const ah = alignHues4(r0.h, r1.h, r2.h, r3.h);
|
|
624
|
+
px.l = catmullRom1D(r0.l, r1.l, r2.l, r3.l, v);
|
|
625
|
+
px.c = catmullRom1D(r0.c, r1.c, r2.c, r3.c, v);
|
|
626
|
+
px.h = catmullRom1D(ah[0], ah[1], ah[2], ah[3], v);
|
|
627
|
+
px.a = catmullRom1D(r0.a, r1.a, r2.a, r3.a, v);
|
|
628
|
+
clampChannels(px);
|
|
629
|
+
out[py * width + pX] = packOklchSingle(px.l, px.c, px.h, px.a);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// smoothstep (cu, cv) to match the regular-grid
|
|
634
|
+
// path. corner colors (u/v = 0 or 1) preserved
|
|
635
|
+
// exactly. shifts the perpendicular-to-edge
|
|
636
|
+
// derivative to zero on both sides → C¹ across
|
|
637
|
+
// the seam between this quad and its neighbour.
|
|
638
|
+
let cu = u, cv = v;
|
|
639
|
+
if (smooth) {
|
|
640
|
+
cu = u * u * (3 - 2 * u);
|
|
641
|
+
cv = v * v * (3 - 2 * v);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Forward bilinear blend of the four corner colors
|
|
645
|
+
// with the recovered local (cu, cv). Same alpha
|
|
646
|
+
// bilinear-lerp pattern as sampleAt — lerpOklchTo
|
|
647
|
+
// only touches L/C/H so we walk alpha inline.
|
|
648
|
+
lerpOklchTo(sTL, sTR, cu, top);
|
|
649
|
+
top.h = normHue(top.h);
|
|
650
|
+
top.a = sTL.a + (sTR.a - sTL.a) * cu;
|
|
651
|
+
lerpOklchTo(sBL, sBR, cu, bot);
|
|
652
|
+
bot.h = normHue(bot.h);
|
|
653
|
+
bot.a = sBL.a + (sBR.a - sBL.a) * cu;
|
|
654
|
+
lerpOklchTo(top, bot, cv, px);
|
|
655
|
+
px.h = normHue(px.h);
|
|
656
|
+
px.a = top.a + (bot.a - top.a) * cv;
|
|
657
|
+
|
|
658
|
+
out[py * width + pX] = packOklchSingle(px.l, px.c, px.h, px.a);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Release internal references. */
|
|
668
|
+
destroy() {
|
|
669
|
+
this.stops = null;
|
|
670
|
+
this._scratchTop = null;
|
|
671
|
+
this._scratchBot = null;
|
|
672
|
+
}
|
|
673
|
+
}
|