@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/CHANGELOG.md +334 -0
- package/README.md +147 -0
- package/llms.txt +83 -0
- package/package.json +23 -11
- package/src/bake.js +36 -0
- package/src/color-convert.js +18 -3
- package/src/index.d.ts +122 -1
- package/src/index.js +2 -1
- package/src/mesh.js +407 -74
package/src/mesh.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { lerpOklchTo } from '@zakkster/lite-color';
|
|
22
|
-
import {
|
|
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
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
138
|
-
//
|
|
139
|
-
|
|
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
|
|
151
|
-
*
|
|
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 /
|
|
187
|
-
y: s.y !== undefined ? s.y : row /
|
|
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 /
|
|
200
|
-
y: row /
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
let
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
const
|
|
353
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
let
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
+
}
|