@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/bake.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import {
15
15
  bakeGradientToUint32,
16
16
  packOklchBufferToUint32,
17
+ packOklchBufferToUint32Dithered,
17
18
  } from '@zakkster/lite-color-engine';
18
19
 
19
20
  const EVEN_SPACING_EPS = 1e-9;
@@ -139,3 +140,38 @@ export function packOklchSingle(l, c, h, alpha = 1) {
139
140
  _packScratch[2] = h;
140
141
  return packOklchBufferToUint32(_packScratch, 0, alpha);
141
142
  }
143
+
144
+ /**
145
+ * Scalar-arg convenience wrapper around lite-color-engine v1.5's
146
+ * `packOklchBufferToUint32Dithered`. Same output byte order (RGBA-LE),
147
+ * same gamma-encode semantics — the only difference from `packOklchSingle`
148
+ * is a threshold-offset applied at the round step: `byte = (enc*255 + noise01) | 0`
149
+ * instead of `(enc*255 + 0.5) | 0`.
150
+ *
151
+ * Contract from the engine (restated for T2 D8):
152
+ * - The **same** noise value is used for R, G, and B at one pixel —
153
+ * luminance-patterned dither, no chroma speckle.
154
+ * - Alpha is undithered (banding on alpha is rarely visible and would
155
+ * interact badly with premultiplication).
156
+ * - `noise01 = 0.5` reproduces `packOklchSingle` exactly (identity
157
+ * anchor — trivially provable by the +0.5 rounding in the plain
158
+ * packer, which is what the dithered form emits at that noise value).
159
+ *
160
+ * Zero-GC: reuses the module-scoped `_packScratch` Float32Array(3) that
161
+ * `packOklchSingle` uses. Consumers must not call both in reentrant
162
+ * contexts (the studio rasterizers never do — one loop, one packer).
163
+ *
164
+ * @param {number} l OKLCH lightness (0..1)
165
+ * @param {number} c OKLCH chroma (~0..0.4)
166
+ * @param {number} h OKLCH hue in degrees
167
+ * @param {number} alpha (0..1); undithered
168
+ * @param {number} noise01 Threshold offset in [0, 1). Typically `(tile[i] + 0.5) / 256`
169
+ * where `tile` is `getBlueNoise64()` from the engine.
170
+ * @returns {number} Uint32 RGBA-LE pixel.
171
+ */
172
+ export function packOklchSingleDithered(l, c, h, alpha, noise01) {
173
+ _packScratch[0] = l;
174
+ _packScratch[1] = c;
175
+ _packScratch[2] = h;
176
+ return packOklchBufferToUint32Dithered(_packScratch, 0, alpha, noise01);
177
+ }
@@ -13,9 +13,13 @@
13
13
 
14
14
  /**
15
15
  * OKLCH → linear sRGB triplet, with sRGB-gamut mapping by chroma reduction.
16
- * Output: [r, g, b] each in [0, 1].
16
+ * Output: `[r, g, b]` each in `[0, 1]`.
17
+ *
18
+ * Zero-GC path: pass a caller-owned 3-element array as `out`. The function
19
+ * writes into it and returns the same reference. Omit `out` (or pass null)
20
+ * to get a fresh allocated array — back-compat with the 3-arg call shape.
17
21
  */
18
- export function oklchToLinearSrgb(L, C, H) {
22
+ export function oklchToLinearSrgb(L, C, H, out) {
19
23
  const hRad = H * Math.PI / 180;
20
24
  const cosH = Math.cos(hRad);
21
25
  const sinH = Math.sin(hRad);
@@ -38,6 +42,7 @@ export function oklchToLinearSrgb(L, C, H) {
38
42
  let [r, g, bb] = getRgb(C);
39
43
 
40
44
  if (r >= 0 && r <= 1 && g >= 0 && g <= 1 && bb >= 0 && bb <= 1) {
45
+ if (out) { out[0] = r; out[1] = g; out[2] = bb; return out; }
41
46
  return [r, g, bb];
42
47
  }
43
48
 
@@ -49,6 +54,7 @@ export function oklchToLinearSrgb(L, C, H) {
49
54
  if (r < 0) r = 0; else if (r > 1) r = 1;
50
55
  if (g < 0) g = 0; else if (g > 1) g = 1;
51
56
  if (bb < 0) bb = 0; else if (bb > 1) bb = 1;
57
+ if (out) { out[0] = r; out[1] = g; out[2] = bb; return out; }
52
58
  return [r, g, bb];
53
59
  }
54
60
 
@@ -64,6 +70,7 @@ export function oklchToLinearSrgb(L, C, H) {
64
70
  hi = midC;
65
71
  }
66
72
  }
73
+ if (out) { out[0] = fitR; out[1] = fitG; out[2] = fitB; return out; }
67
74
  return [fitR, fitG, fitB];
68
75
  }
69
76
 
@@ -111,7 +118,14 @@ function clampByte(v) {
111
118
  /**
112
119
  * Linear sRGB → OKLCH. Used by fromHex and the palette extractor.
113
120
  */
114
- export function linearSrgbToOklch(r, g, b) {
121
+ /**
122
+ * Linear sRGB → OKLCH. Inverse of `oklchToLinearSrgb`.
123
+ *
124
+ * Zero-GC path: pass a caller-owned `{ l, c, h }` as `out`. The function
125
+ * writes into it and returns the same reference. Omit `out` (or pass null)
126
+ * to get a fresh allocated object — back-compat with the 3-arg call shape.
127
+ */
128
+ export function linearSrgbToOklch(r, g, b, out) {
115
129
  const lLms = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
116
130
  const mLms = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
117
131
  const sLms = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
@@ -124,6 +138,7 @@ export function linearSrgbToOklch(r, g, b) {
124
138
  const C = Math.sqrt(a * a + bb * bb);
125
139
  let H = Math.atan2(bb, a) * 180 / Math.PI;
126
140
  if (H < 0) H += 360;
141
+ if (out) { out.l = L; out.c = C; out.h = H; return out; }
127
142
  return { l: L, c: C, h: H };
128
143
  }
129
144
 
package/src/index.d.ts CHANGED
@@ -66,6 +66,23 @@ export interface MeshRasterizeOptions {
66
66
  interpolation?: InterpolationMode;
67
67
  /** Legacy boolean: `true` is equivalent to `interpolation: 'smooth'`. */
68
68
  smooth?: boolean;
69
+ /**
70
+ * v1.2.0 — blue-noise dithering (D8). When `true`, `rasterizeTo` uses
71
+ * `@zakkster/lite-color-engine`'s dithered packer with per-pixel
72
+ * threshold offsets from the engine's shared 64×64 void-and-cluster
73
+ * tile. Every channel is within ±1 of the undithered output; the
74
+ * same offset is shared across R/G/B at each pixel (no chroma
75
+ * speckle); alpha is undithered.
76
+ *
77
+ * `dither: false` (or absent) walks the same code as v1.2.0 — the
78
+ * byte-parity guarantee for undithered output is preserved.
79
+ *
80
+ * Not yet supported on `rasterizeDeformedTo` — throws
81
+ * `WRAP_DEFORMED_UNSUPPORTED` first if the mesh is wrapped, and
82
+ * silently ignores the flag otherwise (deformed + dither planned
83
+ * alongside the v1.3 ghost-quad seam work).
84
+ */
85
+ dither?: boolean;
69
86
  }
70
87
 
71
88
  /**
@@ -73,19 +90,58 @@ export interface MeshRasterizeOptions {
73
90
  * mesh. Pure function. Used internally by MeshGradient when no `stops` array
74
91
  * is supplied; re-exported for callers who want to inspect or override the
75
92
  * default field.
93
+ *
94
+ * v1.2.0 — trailing `wrapX` / `wrapY` args are optional and default to
95
+ * false. When set, the corresponding axis uses a periodic parameterization
96
+ * (uniform 360/cols hue step for wrapX; sinusoidal L and drift for wrapY)
97
+ * so the default mesh doesn't compress aperiodic sweeps into the final
98
+ * cell. Four-arg calls stay byte-identical to v1.1.0.
76
99
  */
77
100
  export function defaultMeshColor(
78
101
  col: number,
79
102
  row: number,
80
103
  cols: number,
81
104
  rows: number,
105
+ wrapX?: boolean,
106
+ wrapY?: boolean,
82
107
  ): OklchColor;
83
108
 
109
+ /**
110
+ * Constructor options for {@link MeshGradient} (v1.2.0+).
111
+ *
112
+ * Wrap flags are structural — they change the UV period, the cell count,
113
+ * and the default control-point positions. Both flags are independent
114
+ * (torus = both on; cylinder = one on). Non-wrap behaviour is byte-parity
115
+ * with v1.1.0 when opts is absent or both flags are false.
116
+ */
117
+ export interface MeshGradientOptions {
118
+ /**
119
+ * Treat the X axis as cyclic. `sampleAt` wraps u via `u - Math.floor(u)`,
120
+ * `rasterizeTo` samples the period (`x / width`, not `x / (width - 1)`),
121
+ * cubic mode reads real neighbours across the seam via modulo indexing
122
+ * (C¹ continuity). `rasterizeDeformedTo` throws
123
+ * `WRAP_DEFORMED_UNSUPPORTED` — ghost-quad seam crossing is deferred
124
+ * to v1.3.
125
+ */
126
+ wrapX?: boolean;
127
+ /** Symmetric to {@link wrapX} on the Y axis. */
128
+ wrapY?: boolean;
129
+ }
130
+
84
131
  export class MeshGradient {
85
- constructor(cols: number, rows: number, stops?: ReadonlyArray<MeshStop>);
132
+ constructor(
133
+ cols: number,
134
+ rows: number,
135
+ stops?: ReadonlyArray<MeshStop>,
136
+ opts?: MeshGradientOptions,
137
+ );
86
138
 
87
139
  readonly cols: number;
88
140
  readonly rows: number;
141
+ /** v1.2.0 — reflects the constructor opts; `false` when opts absent. */
142
+ readonly wrapX: boolean;
143
+ /** v1.2.0 — reflects the constructor opts; `false` when opts absent. */
144
+ readonly wrapY: boolean;
89
145
  readonly stops: MeshStopFull[];
90
146
 
91
147
  /** Read the (col, row) color into a caller-owned out. Zero-GC. */
@@ -108,6 +164,9 @@ export class MeshGradient {
108
164
  * - true -> 'smooth' (smoothstep)
109
165
  * - 'cubic' -> Catmull-Rom 2D
110
166
  * - any of the strings above explicitly
167
+ *
168
+ * v1.2.0 — on wrapped axes, u/v accept any float; input is period-wrapped
169
+ * via `u - Math.floor(u)` before mapping to a cell coord.
111
170
  */
112
171
  sampleAt<T extends Partial<OklchColorA>>(
113
172
  u: number,
@@ -119,6 +178,10 @@ export class MeshGradient {
119
178
  /**
120
179
  * Rasterize on the REGULAR grid into a packed-RGBA Uint32Array (little-
121
180
  * endian byte order; aliasable as Uint8ClampedArray for ImageData).
181
+ *
182
+ * v1.2.0 — on wrapped axes, samples the period (drops the `- 1`
183
+ * divisor) so pixel column 0 of the "next tile" would land at u ≡ 0,
184
+ * enabling seamless `drawImage`-style tiling.
122
185
  */
123
186
  rasterizeTo(
124
187
  out: Uint32Array,
@@ -131,6 +194,10 @@ export class MeshGradient {
131
194
  * Rasterize honoring control-point positions (deformable mesh). Pixels
132
195
  * outside any quad are LEFT UNTOUCHED -- callers that want a clean
133
196
  * canvas must fill or zero `out` first.
197
+ *
198
+ * v1.2.0 — throws with `err.code = 'WRAP_DEFORMED_UNSUPPORTED'` when
199
+ * called on a wrapped mesh. Ghost-quad seam crossing is planned for
200
+ * v1.3; the current behaviour is loud failure instead of silent seams.
134
201
  */
135
202
  rasterizeDeformedTo(
136
203
  out: Uint32Array,
@@ -143,6 +210,45 @@ export class MeshGradient {
143
210
  destroy(): void;
144
211
  }
145
212
 
213
+ // ---------------------------------------------------------------------------
214
+ // Monochrome mesh (v1.1.0)
215
+ // ---------------------------------------------------------------------------
216
+
217
+ export type MonoMode = 'tinted' | 'grayscale';
218
+ export type MonoMeshDirection = 'horizontal' | 'vertical' | 'diagonal' | 'radial';
219
+
220
+ export interface MonochromeMeshOptions {
221
+ /**
222
+ * Chroma handling.
223
+ * - `'tinted'` (default): retain base color's chroma/hue at every point.
224
+ * - `'grayscale'`: force chroma to 0 for pure achromatic tones.
225
+ */
226
+ mode?: MonoMode;
227
+ /** L-axis endpoints [lo, hi], must satisfy `0 <= lo < hi <= 1`. Default `[0, 1]`. */
228
+ range?: readonly [number, number];
229
+ /**
230
+ * How L varies across the mesh:
231
+ * - `'horizontal'`: left-to-right, uniform per row.
232
+ * - `'vertical'`: top-to-bottom, uniform per column.
233
+ * - `'diagonal'` (default): top-left corner (lo) to bottom-right corner (hi).
234
+ * - `'radial'`: center (lo) outward to corners (hi).
235
+ */
236
+ direction?: MonoMeshDirection;
237
+ }
238
+
239
+ /**
240
+ * Build a monochromatic MeshGradient from a single base OKLCH color.
241
+ * Chroma and hue are held constant across every control point; only
242
+ * lightness varies according to `direction`. Client-work-friendly:
243
+ * never looks "AI-generated" because the palette is fixed to one brand tone.
244
+ */
245
+ export function monochromeMesh(
246
+ base: OklchColor,
247
+ cols: number,
248
+ rows: number,
249
+ opts?: MonochromeMeshOptions
250
+ ): MeshGradient;
251
+
146
252
  // ---------------------------------------------------------------------------
147
253
  // CSS emitters
148
254
  // ---------------------------------------------------------------------------
@@ -274,6 +380,21 @@ export function sampleLut(lut: Uint32Array, t: number): number;
274
380
  /** Pack a single OKLCH color directly to a 32-bit RGBA value. */
275
381
  export function packOklchSingle(l: number, c: number, h: number, alpha?: number): number;
276
382
 
383
+ /**
384
+ * v1.2.0 — dithered scalar packer. Threshold-offset gamma round via
385
+ * `noise01`: `byte = (encoded * 255 + noise01) | 0`. `noise01 = 0.5`
386
+ * reproduces `packOklchSingle` exactly (identity anchor). Same buffer
387
+ * scratch as `packOklchSingle` — safe to alternate between calls, but
388
+ * do NOT interleave with the plain packer in reentrant contexts.
389
+ */
390
+ export function packOklchSingleDithered(
391
+ l: number,
392
+ c: number,
393
+ h: number,
394
+ alpha: number,
395
+ noise01: number,
396
+ ): number;
397
+
277
398
  // ---------------------------------------------------------------------------
278
399
  // Palette extraction
279
400
  // ---------------------------------------------------------------------------
package/src/index.js CHANGED
@@ -20,8 +20,9 @@ export {
20
20
  flattenStopsToBuffer,
21
21
  sampleLut,
22
22
  packOklchSingle,
23
+ packOklchSingleDithered,
23
24
  } from './bake.js';
24
- export { MeshGradient, defaultMeshColor } from './mesh.js';
25
+ export { MeshGradient, defaultMeshColor, monochromeMesh } from './mesh.js';
25
26
  export { formatCssLinear, formatCssRadial, formatCssConic } from './css-emitters.js';
26
27
  export { formatCssMesh } from './mesh-css.js';
27
28
  export {