@zakkster/lite-gradient-studio 1.0.1 → 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/src/mesh.js CHANGED
@@ -19,7 +19,8 @@
19
19
  */
20
20
 
21
21
  import { lerpOklchTo } from '@zakkster/lite-color';
22
- import { packOklchSingle } from './bake.js';
22
+ import { getBlueNoise64 } from '@zakkster/lite-color-engine';
23
+ import { packOklchSingle, packOklchSingleDithered } from './bake.js';
23
24
 
24
25
  /** Normalize a hue angle to [0, 360). Handles negative and >=360 inputs. */
25
26
  function normHue(h) {
@@ -115,28 +116,63 @@ function resolveInterpMode(opts) {
115
116
  *
116
117
  * Pure function. Same input always returns the same output.
117
118
  *
118
- * @param {number} col 0..cols-1
119
- * @param {number} row 0..rows-1
120
- * @param {number} cols >= 2
121
- * @param {number} rows >= 2
119
+ * v1.2.0: the trailing `wrapX` and `wrapY` args are optional and default
120
+ * to `false`; when either is set, the corresponding axis's parameterization
121
+ * switches to a periodic form so a wrapped default mesh doesn't compress
122
+ * 120° of hue into the final cell. Callers passing four args as before
123
+ * get byte-identical output.
124
+ *
125
+ * @param {number} col 0..cols-1
126
+ * @param {number} row 0..rows-1
127
+ * @param {number} cols >= 2
128
+ * @param {number} rows >= 2
129
+ * @param {boolean} [wrapX=false] v1.2.0 — when true, hue advances by
130
+ * `360/cols` per column (uniform step everywhere including the seam,
131
+ * no aperiodic compression at the wrap boundary).
132
+ * @param {boolean} [wrapY=false] v1.2.0 — when true, the small row-driven
133
+ * hue drift and the L gradient both take a sinusoidal (periodic) shape
134
+ * so `row = 0` and `row = rows` land on the same color.
122
135
  * @returns {{l:number,c:number,h:number}} fresh OKLCH triplet
123
136
  */
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);
137
+ export function defaultMeshColor(col, row, cols, rows, wrapX = false, wrapY = false) {
138
+ // Column parameter periodic when wrapping in x. On a wrapped axis
139
+ // the "cells" go 0..cols and cell (cols) is identified with cell 0, so
140
+ // `col / cols` (not `col / (cols - 1)`) is the right period step.
141
+ const cT = wrapX
142
+ ? col / cols
143
+ : (cols === 1 ? 0.5 : col / (cols - 1));
144
+ const rT = wrapY
145
+ ? row / rows
146
+ : (rows === 1 ? 0.5 : row / (rows - 1));
147
+
148
+ // L: lighter at top, darker at bottom. In wrapY mode the top and bottom
149
+ // must agree, so drive L with cos(2π·rT) instead of a linear ramp —
150
+ // rT = 0 and rT = 1 both give cos = 1, i.e. the same L.
151
+ const l = wrapY
152
+ ? 0.55 + 0.15 * Math.cos(2 * Math.PI * rT)
153
+ : 0.70 - 0.30 * rT;
154
+
155
+ // C: tent peak at the center. In wrapY mode the "distance from center"
156
+ // rDist has to be periodic; use `|sin(π·rT)|` which peaks at rT=0.5 and
157
+ // reaches 0 at rT=0 and rT=1 — same shape, seamless. Same for wrapX.
158
+ const cDist = wrapX
159
+ ? Math.abs(Math.sin(Math.PI * cT))
160
+ : 1 - Math.abs(cT * 2 - 1);
161
+ const rDist = wrapY
162
+ ? Math.abs(Math.sin(Math.PI * rT))
163
+ : 1 - Math.abs(rT * 2 - 1);
135
164
  const c = 0.15 + 0.10 * cDist * rDist;
136
165
 
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);
166
+ // H: sweep across columns + small diagonal drift by row.
167
+ // - wrapX: uniform 360/cols step per column so cT = 0 and cT = 1 close
168
+ // at the same hue (both are 0 mod 360). Base offset 240 preserves the
169
+ // familiar "starts in blue" palette.
170
+ // - wrapY: the row drift becomes periodic via sin(2π·rT) so top and
171
+ // bottom rows share the drift value 0. Amplitude kept at 30° to
172
+ // match the linear-mode magnitude.
173
+ const hueSweep = wrapX ? 360 * cT : 120 * cT;
174
+ const hueDrift = wrapY ? 30 * Math.sin(2 * Math.PI * rT) : 30 * rT;
175
+ const h = normHue(240 + hueSweep + hueDrift);
140
176
 
141
177
  return { l, c, h };
142
178
  }
@@ -147,10 +183,26 @@ export class MeshGradient {
147
183
  * @param {number} rows Number of rows (>= 2).
148
184
  * @param {Array<{l:number,c:number,h:number}>} [stops]
149
185
  * 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).
186
+ * a sensible default mesh is generated by `defaultMeshColor`.
187
+ * @param {object} [opts]
188
+ * @param {boolean} [opts.wrapX=false] v1.2.0 — treat the X axis as
189
+ * cyclic. Structural: changes the UV period (`u = 1` wraps to `u = 0`),
190
+ * the cell count (`cols` cells instead of `cols - 1`), and the default
191
+ * control-point positions (`col / cols`). `sampleAt` wraps the u
192
+ * coordinate via `u = u - Math.floor(u)`. Cubic mode reads real
193
+ * neighbours across the seam via modulo indexing → C¹ continuity at
194
+ * `u = 0`. `rasterizeTo` samples the period, not the closed interval.
195
+ * `rasterizeDeformedTo` throws `WRAP_DEFORMED_UNSUPPORTED` — deformed
196
+ * + wrap needs ghost quads that cross the seam, deferred to v1.3.
197
+ * `cols` must be `>= 2` for any mesh, wrapped or not (the outer
198
+ * integer-check on the constructor rejects `cols < 2` before wrap
199
+ * is inspected). Degenerate `cols = 2` wrapped IS legal — cubic
200
+ * neighbour indices alternate `(a, b, a, b)` and the Catmull-Rom
201
+ * basis handles it as a low-amplitude oscillation.
202
+ * @param {boolean} [opts.wrapY=false] v1.2.0 — same for the Y axis.
203
+ * Independent of `wrapX` (torus = both, cylinder = one).
152
204
  */
153
- constructor(cols, rows, stops) {
205
+ constructor(cols, rows, stops, opts) {
154
206
  if (!Number.isInteger(cols) || cols < 2) {
155
207
  throw new Error('MeshGradient: cols must be an integer >= 2');
156
208
  }
@@ -158,10 +210,33 @@ export class MeshGradient {
158
210
  throw new Error('MeshGradient: rows must be an integer >= 2');
159
211
  }
160
212
 
213
+ // v1.2.0 wrap opts. Both default false; when either flag is off the
214
+ // resulting object walks the same code paths as v1.1.0 (byte-parity
215
+ // is a hard exit-gate for T2).
216
+ //
217
+ // The roadmap's `WRAP_AXIS_TOO_SMALL` guard (cols=1 under modulo
218
+ // collapses every cubic neighbour index to 0, silently breaking the
219
+ // basis) is already covered by the outer `cols/rows < 2` guards
220
+ // above — cols=1 can never construct a MeshGradient at all, wrapped
221
+ // or not. `cols === 2` wrapped IS legal: neighbour indices alternate
222
+ // `(a, b, a, b)` and Catmull-Rom handles it as a low-amplitude
223
+ // oscillation.
224
+ const wrapX = opts != null && opts.wrapX === true;
225
+ const wrapY = opts != null && opts.wrapY === true;
226
+
161
227
  this.cols = cols;
162
228
  this.rows = rows;
229
+ this.wrapX = wrapX;
230
+ this.wrapY = wrapY;
163
231
  const total = cols * rows;
164
232
 
233
+ // Default-position denominators: on a wrapped axis there are `cols`
234
+ // cells (not `cols - 1`), so the natural spacing is `col / cols`.
235
+ // Non-wrapped axes keep the endpoint-inclusive `col / (cols - 1)`
236
+ // mapping.
237
+ const xDenom = wrapX ? cols : (cols - 1);
238
+ const yDenom = wrapY ? rows : (rows - 1);
239
+
165
240
  if (stops) {
166
241
  if (stops.length !== total) {
167
242
  throw new Error(
@@ -183,21 +258,23 @@ export class MeshGradient {
183
258
  this.stops[i] = {
184
259
  l: s.l, c: s.c, h: s.h,
185
260
  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),
261
+ x: s.x !== undefined ? s.x : col / xDenom,
262
+ y: s.y !== undefined ? s.y : row / yDenom,
188
263
  };
189
264
  }
190
265
  } else {
191
266
  // Procedural default: smooth aurora at any size, no tiling.
267
+ // Passes wrapX/wrapY through so the aperiodic hue sweep gets
268
+ // swapped for the periodic form on wrapped axes (D6 in T2).
192
269
  this.stops = new Array(total);
193
270
  for (let i = 0; i < total; i++) {
194
271
  const col = i % cols;
195
272
  const row = (i / cols) | 0;
196
- const src = defaultMeshColor(col, row, cols, rows);
273
+ const src = defaultMeshColor(col, row, cols, rows, wrapX, wrapY);
197
274
  this.stops[i] = {
198
275
  l: src.l, c: src.c, h: src.h, a: 1,
199
- x: col / (cols - 1),
200
- y: row / (rows - 1),
276
+ x: col / xDenom,
277
+ y: row / yDenom,
201
278
  };
202
279
  }
203
280
  }
@@ -320,24 +397,44 @@ export class MeshGradient {
320
397
  : modeOrSmooth;
321
398
  if (mode === 'cubic') return this._sampleAtCubic(u, v, out);
322
399
 
323
- // Clamp to unit square.
324
- if (u < 0) u = 0; else if (u > 1) u = 1;
325
- if (v < 0) v = 0; else if (v > 1) v = 1;
326
-
327
400
  const cols = this.cols;
328
401
  const rows = this.rows;
329
402
  const stops = this.stops;
330
403
 
331
- // Map to grid coords. Last cell index is (cols-2, rows-2) the
332
- // cell whose right/bottom edge is the mesh boundary.
333
- const fu = u * (cols - 1);
334
- const fv = v * (rows - 1);
335
- let col = fu | 0;
336
- let row = fv | 0;
337
- let cu = fu - col;
338
- let cv = fv - row;
339
- if (col >= cols - 1) { col = cols - 2; cu = 1; }
340
- if (row >= rows - 1) { row = rows - 2; cv = 1; }
404
+ // v1.2.0 axis handling wrapped axes use period wrap
405
+ // `u - Math.floor(u)` instead of the unit-square clamp. Cell count
406
+ // becomes `cols` (not `cols - 1`) so `sampleAt(1, v)` maps to the
407
+ // same cell coordinate as `sampleAt(0, v)`. Non-wrapped axes keep
408
+ // v1.1.0 semantics byte-for-byte.
409
+ let fu, col, cu;
410
+ if (this.wrapX) {
411
+ const uw = u - Math.floor(u);
412
+ fu = uw * cols;
413
+ col = fu | 0;
414
+ cu = fu - col;
415
+ if (col >= cols) col = 0; // safety net for uw ≈ 1.0 float rounding
416
+ } else {
417
+ if (u < 0) u = 0; else if (u > 1) u = 1;
418
+ fu = u * (cols - 1);
419
+ col = fu | 0;
420
+ cu = fu - col;
421
+ if (col >= cols - 1) { col = cols - 2; cu = 1; }
422
+ }
423
+
424
+ let fv, row, cv;
425
+ if (this.wrapY) {
426
+ const vw = v - Math.floor(v);
427
+ fv = vw * rows;
428
+ row = fv | 0;
429
+ cv = fv - row;
430
+ if (row >= rows) row = 0;
431
+ } else {
432
+ if (v < 0) v = 0; else if (v > 1) v = 1;
433
+ fv = v * (rows - 1);
434
+ row = fv | 0;
435
+ cv = fv - row;
436
+ if (row >= rows - 1) { row = rows - 2; cv = 1; }
437
+ }
341
438
 
342
439
  if (mode === 'smooth') {
343
440
  // smoothstep eases the (cu, cv) → blend mapping; corner colors
@@ -346,11 +443,15 @@ export class MeshGradient {
346
443
  cv = cv * cv * (3 - 2 * cv);
347
444
  }
348
445
 
349
- const base = row * cols + col;
350
- const c00 = stops[base];
351
- const c10 = stops[base + 1];
352
- const c01 = stops[base + cols];
353
- const c11 = stops[base + cols + 1];
446
+ // Neighbour indices modulo on wrapped axes so the last cell blends
447
+ // back to the first.
448
+ const colN = this.wrapX ? (col + 1) % cols : col + 1;
449
+ const rowN = this.wrapY ? (row + 1) % rows : row + 1;
450
+
451
+ const c00 = stops[row * cols + col];
452
+ const c10 = stops[row * cols + colN];
453
+ const c01 = stops[rowN * cols + col];
454
+ const c11 = stops[rowN * cols + colN];
354
455
 
355
456
  // Top edge then bottom edge into scratch, then v-lerp into out.
356
457
  // Normalize each intermediate hue so subsequent lerps see a value
@@ -381,35 +482,80 @@ export class MeshGradient {
381
482
  * clamped to valid ranges since Catmull-Rom can overshoot.
382
483
  */
383
484
  _sampleAtCubic(u, v, out) {
384
- if (u < 0) u = 0; else if (u > 1) u = 1;
385
- if (v < 0) v = 0; else if (v > 1) v = 1;
386
-
387
485
  const cols = this.cols;
388
486
  const rows = this.rows;
389
487
 
390
- const fu = u * (cols - 1);
391
- const fv = v * (rows - 1);
392
- let col = fu | 0;
393
- let row = fv | 0;
394
- let cu = fu - col;
395
- let cv = fv - row;
396
- if (col >= cols - 1) { col = cols - 2; cu = 1; }
397
- if (row >= rows - 1) { row = rows - 2; cv = 1; }
398
-
399
- // 4×4 neighbour columns + rows, clamped to grid bounds. At a
400
- // boundary patch the spline degrades toward bilinear (duplicate
401
- // endpoints give a flat tangent there), which is the right
402
- // behaviour there's no extrapolation data to inform a
403
- // sharper curve outside the grid.
404
- const im1 = col - 1 < 0 ? 0 : col - 1;
405
- const i0 = col;
406
- const i1 = col + 1;
407
- const i2 = col + 2 >= cols ? cols - 1 : col + 2;
408
-
409
- const jm1 = row - 1 < 0 ? 0 : row - 1;
410
- const j0 = row;
411
- const j1 = row + 1;
412
- const j2 = row + 2 >= rows ? rows - 1 : row + 2;
488
+ // Wrapped-axis handling same shape as `sampleAt`: period-wrap
489
+ // the coord and use `cols` cells instead of `cols - 1`.
490
+ let fu, col, cu;
491
+ if (this.wrapX) {
492
+ const uw = u - Math.floor(u);
493
+ fu = uw * cols;
494
+ col = fu | 0;
495
+ cu = fu - col;
496
+ if (col >= cols) col = 0;
497
+ } else {
498
+ if (u < 0) u = 0; else if (u > 1) u = 1;
499
+ fu = u * (cols - 1);
500
+ col = fu | 0;
501
+ cu = fu - col;
502
+ if (col >= cols - 1) { col = cols - 2; cu = 1; }
503
+ }
504
+
505
+ let fv, row, cv;
506
+ if (this.wrapY) {
507
+ const vw = v - Math.floor(v);
508
+ fv = vw * rows;
509
+ row = fv | 0;
510
+ cv = fv - row;
511
+ if (row >= rows) row = 0;
512
+ } else {
513
+ if (v < 0) v = 0; else if (v > 1) v = 1;
514
+ fv = v * (rows - 1);
515
+ row = fv | 0;
516
+ cv = fv - row;
517
+ if (row >= rows - 1) { row = rows - 2; cv = 1; }
518
+ }
519
+
520
+ // 4×4 neighbour columns + rows. This is D3 — the flagship. On a
521
+ // wrapped axis, the (col - 1) and (col + 2) neighbours read real
522
+ // cells across the seam via modulo instead of clamping to a
523
+ // duplicated endpoint. That is what gives C¹ continuity at the
524
+ // tile boundary — bilinear and smooth get seamlessness from the
525
+ // wrap coord alone, but cubic needs its input samples to actually
526
+ // exist across the seam. Non-wrapped axes keep the v1.1.0 clamp
527
+ // behaviour byte-for-byte.
528
+ //
529
+ // Modulo formula: `((x % n) + n) % n` is the classic safe form for
530
+ // possibly-negative dividends. On wrapped axes `col` is already in
531
+ // `[0, cols)` from the cell-index arithmetic above, so `col - 1`
532
+ // can be -1 (needs the +n rescue) and `col + 2` can be `cols` or
533
+ // `cols + 1` (harmless — the second `% n` normalizes them).
534
+ let im1, i0, i1, i2;
535
+ if (this.wrapX) {
536
+ im1 = ((col - 1) % cols + cols) % cols;
537
+ i0 = col;
538
+ i1 = (col + 1) % cols;
539
+ i2 = (col + 2) % cols;
540
+ } else {
541
+ im1 = col - 1 < 0 ? 0 : col - 1;
542
+ i0 = col;
543
+ i1 = col + 1;
544
+ i2 = col + 2 >= cols ? cols - 1 : col + 2;
545
+ }
546
+
547
+ let jm1, j0, j1, j2;
548
+ if (this.wrapY) {
549
+ jm1 = ((row - 1) % rows + rows) % rows;
550
+ j0 = row;
551
+ j1 = (row + 1) % rows;
552
+ j2 = (row + 2) % rows;
553
+ } else {
554
+ jm1 = row - 1 < 0 ? 0 : row - 1;
555
+ j0 = row;
556
+ j1 = row + 1;
557
+ j2 = row + 2 >= rows ? rows - 1 : row + 2;
558
+ }
413
559
 
414
560
  // Step 1: blend each of the 4 rows along x at parameter cu.
415
561
  this._cubicRow(jm1, im1, i0, i1, i2, cu, this._scratchCubicRows[0]);
@@ -471,8 +617,47 @@ export class MeshGradient {
471
617
  }
472
618
  const mode = resolveInterpMode(opts);
473
619
  const tmp = { l: 0, c: 0, h: 0, a: 1 };
474
- const wInv = 1 / (width - 1);
475
- const hInv = 1 / (height - 1);
620
+ // v1.2.0 D4: on a wrapped axis, sample the period (`x / width`),
621
+ // NOT the closed interval (`x / (width - 1)`). Pixel column 0 of
622
+ // the "next tile" would land at `u = 1 ≡ 0` — no duplicated edge
623
+ // column, so `drawImage`-based tiling of the rasterized output
624
+ // butts perfectly. Non-wrapped axes keep v1.1.0 semantics.
625
+ const wInv = this.wrapX ? 1 / width : 1 / (width - 1);
626
+ const hInv = this.wrapY ? 1 / height : 1 / (height - 1);
627
+
628
+ // v1.2.0 — D8: dither branch resolved ONCE per call, two loop
629
+ // bodies. `dither: false` (or absent) walks the exact same code
630
+ // as v1.2.0 — the byte-parity guarantee for undithered output
631
+ // is the whole reason the branch is out here and not per-pixel.
632
+ //
633
+ // Dither mechanics (contract owned by lite-color-engine v1.5, F1):
634
+ // - Blue-noise tile is 64×64 bytes (0..255), shared reference.
635
+ // - Per pixel: `noiseByte = tile[((y & 63) << 6) | (x & 63)]`
636
+ // — pure bit ops (torus wrap via mask, row stride via shift).
637
+ // - `noise01 = (noiseByte + 0.5) / 256` centers the perturbation
638
+ // around 0.5 so it matches the plain packer's rounding offset.
639
+ // Consequence: uniform 128 fills would still round identically;
640
+ // the actual dither effect surfaces on smooth ramps where the
641
+ // encoded byte lands near an integer boundary.
642
+ // - Same `noise01` reused for R/G/B at the pixel — luminance-
643
+ // patterned dither, no chroma speckle.
644
+ const dither = opts != null && opts.dither === true;
645
+ if (dither) {
646
+ const tile = getBlueNoise64(); // shared Uint8Array(4096)
647
+ const INV_256 = 1 / 256;
648
+ let i = 0;
649
+ for (let y = 0; y < height; y++) {
650
+ const v = y * hInv;
651
+ const rowBase = (y & 63) << 6;
652
+ for (let x = 0; x < width; x++) {
653
+ const u = x * wInv;
654
+ this.sampleAt(u, v, tmp, mode);
655
+ const noise01 = (tile[rowBase | (x & 63)] + 0.5) * INV_256;
656
+ out[i++] = packOklchSingleDithered(tmp.l, tmp.c, tmp.h, tmp.a, noise01);
657
+ }
658
+ }
659
+ return out;
660
+ }
476
661
  let i = 0;
477
662
  for (let y = 0; y < height; y++) {
478
663
  const v = y * hInv;
@@ -521,6 +706,21 @@ export class MeshGradient {
521
706
  if (!(out instanceof Uint32Array) || out.length < width * height) {
522
707
  throw new Error('MeshGradient.rasterizeDeformedTo: out must be a Uint32Array with length >= width*height');
523
708
  }
709
+ // v1.2.0 — D5: deformed + wrap is a real feature (ghost quads
710
+ // crossing the seam, Newton solve against wrapped corner positions),
711
+ // deferred to v1.3. Silent seamed output is worse than no output —
712
+ // fail loudly with a code consumers can branch on cleanly instead
713
+ // of string-matching. The `WRAP_AXIS_TOO_SMALL` guard in the
714
+ // constructor carries the same `err.code` pattern.
715
+ if (this.wrapX || this.wrapY) {
716
+ const err = new Error(
717
+ 'MeshGradient.rasterizeDeformedTo: deformed rasterization on a ' +
718
+ 'wrapped mesh is not supported in v1.2 (planned for v1.3). ' +
719
+ 'Use rasterizeTo (regular grid) for wrapped meshes.'
720
+ );
721
+ err.code = 'WRAP_DEFORMED_UNSUPPORTED';
722
+ throw err;
723
+ }
524
724
  const mode = resolveInterpMode(opts);
525
725
  const smooth = mode === 'smooth';
526
726
  const cubic = mode === 'cubic';
@@ -678,3 +878,136 @@ export class MeshGradient {
678
878
  this._scratchBot = null;
679
879
  }
680
880
  }
881
+
882
+ // ---- Monochrome mesh (v1.1.0) --------------------------------------------
883
+
884
+ /**
885
+ * Build a monochromatic MeshGradient from a single base OKLCH color.
886
+ *
887
+ * Chroma and hue are held constant across every control point in the mesh;
888
+ * only lightness varies according to `direction`. This is the mesh-level
889
+ * analogue to lite-gradient's `monochromeGradient(base, opts)` — a
890
+ * client-work-friendly mesh that never looks like an "AI-generated random
891
+ * gradient" because the palette is fixed to a single brand tone.
892
+ *
893
+ * Typical use: subtle premium backgrounds for cards, hero sections,
894
+ * editorial layouts. The mesh structure means users can post-hoc
895
+ * `setPointPosition(...)` control points off-grid to warp the L
896
+ * distribution — impossible with a flat 1D gradient.
897
+ *
898
+ * @param {{ l: number, c: number, h: number }} base
899
+ * OKLCH base color. `c` and `h` are held constant across the mesh
900
+ * (or c=0 if `mode: 'grayscale'`).
901
+ * @param {number} cols Number of columns (integer >= 2).
902
+ * @param {number} rows Number of rows (integer >= 2).
903
+ * @param {Object} [opts]
904
+ * @param {'tinted' | 'grayscale'} [opts.mode='tinted']
905
+ * @param {[number, number]} [opts.range=[0, 1]]
906
+ * L-axis endpoints. Must satisfy `0 <= lo < hi <= 1`.
907
+ * @param {'horizontal' | 'vertical' | 'diagonal' | 'radial'} [opts.direction='diagonal']
908
+ * How L varies across the mesh:
909
+ * - `'horizontal'`: L varies left-to-right, uniform across each row.
910
+ * - `'vertical'`: L varies top-to-bottom, uniform across each column.
911
+ * - `'diagonal'`: L varies from top-left (lo) to bottom-right (hi).
912
+ * - `'radial'`: L is `lo` at center, `hi` at corners.
913
+ * @returns {MeshGradient}
914
+ *
915
+ * @throws {TypeError} On invalid `base`, `mode`, `direction`, or `range` shape.
916
+ * @throws {RangeError} On invalid `range` values or `cols`/`rows` < 2.
917
+ *
918
+ * @example
919
+ * // Subtle premium background for a brand card
920
+ * const mesh = monochromeMesh({ l: 0.5, c: 0.06, h: 245 }, 3, 3);
921
+ * const buf = new Uint32Array(800 * 600);
922
+ * mesh.rasterizeTo(buf, 800, 600);
923
+ * const img = new ImageData(new Uint8ClampedArray(buf.buffer), 800, 600);
924
+ * ctx.putImageData(img, 0, 0);
925
+ */
926
+ export function monochromeMesh(base, cols, rows, opts) {
927
+ if (base == null || typeof base !== 'object' ||
928
+ typeof base.l !== 'number' || typeof base.c !== 'number' ||
929
+ typeof base.h !== 'number') {
930
+ throw new TypeError(
931
+ 'monochromeMesh: base must be { l, c, h } with numeric fields'
932
+ );
933
+ }
934
+ if (!Number.isInteger(cols) || cols < 2) {
935
+ throw new RangeError(
936
+ 'monochromeMesh: cols must be an integer >= 2, got ' + cols
937
+ );
938
+ }
939
+ if (!Number.isInteger(rows) || rows < 2) {
940
+ throw new RangeError(
941
+ 'monochromeMesh: rows must be an integer >= 2, got ' + rows
942
+ );
943
+ }
944
+
945
+ const o = opts || {};
946
+ const mode = o.mode == null ? 'tinted' : o.mode;
947
+ const range = o.range == null ? [0, 1] : o.range;
948
+ const direction = o.direction == null ? 'diagonal' : o.direction;
949
+
950
+ if (mode !== 'tinted' && mode !== 'grayscale') {
951
+ throw new TypeError(
952
+ 'monochromeMesh: mode must be "tinted" or "grayscale", got ' + mode
953
+ );
954
+ }
955
+ if (!Array.isArray(range) || range.length !== 2) {
956
+ throw new TypeError(
957
+ 'monochromeMesh: range must be a two-element [lo, hi] array'
958
+ );
959
+ }
960
+ const lo = range[0];
961
+ const hi = range[1];
962
+ if (typeof lo !== 'number' || typeof hi !== 'number' ||
963
+ !(lo >= 0 && hi <= 1 && lo < hi)) {
964
+ throw new RangeError(
965
+ 'monochromeMesh: range must satisfy 0 <= lo < hi <= 1, got [' +
966
+ lo + ', ' + hi + ']'
967
+ );
968
+ }
969
+ if (direction !== 'horizontal' && direction !== 'vertical' &&
970
+ direction !== 'diagonal' && direction !== 'radial') {
971
+ throw new TypeError(
972
+ 'monochromeMesh: direction must be "horizontal", "vertical", ' +
973
+ '"diagonal", or "radial", got ' + direction
974
+ );
975
+ }
976
+
977
+ const c = mode === 'grayscale' ? 0 : base.c;
978
+ const h = base.h;
979
+ const span = hi - lo;
980
+ const cx = (cols - 1) / 2;
981
+ const cy = (rows - 1) / 2;
982
+ // Half-diagonal from center to a corner, used to normalize radial t.
983
+ const maxRadial = Math.sqrt(cx * cx + cy * cy);
984
+ // Diagonal length in grid units (top-left to bottom-right corner),
985
+ // used to normalize diagonal t.
986
+ const diagDenom = (cols - 1) + (rows - 1);
987
+
988
+ const total = cols * rows;
989
+ const stops = new Array(total);
990
+
991
+ for (let i = 0; i < total; i++) {
992
+ const col = i % cols;
993
+ const row = (i / cols) | 0;
994
+
995
+ let t;
996
+ if (direction === 'horizontal') {
997
+ t = cols === 1 ? 0 : col / (cols - 1);
998
+ } else if (direction === 'vertical') {
999
+ t = rows === 1 ? 0 : row / (rows - 1);
1000
+ } else if (direction === 'diagonal') {
1001
+ t = diagDenom === 0 ? 0 : (col + row) / diagDenom;
1002
+ } else {
1003
+ // radial: 0 at center, 1 at corners
1004
+ const dx = col - cx;
1005
+ const dy = row - cy;
1006
+ t = maxRadial === 0 ? 0 : Math.sqrt(dx * dx + dy * dy) / maxRadial;
1007
+ }
1008
+
1009
+ stops[i] = { l: lo + span * t, c, h };
1010
+ }
1011
+
1012
+ return new MeshGradient(cols, rows, stops);
1013
+ }