@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/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
+ }