@zakkster/lite-color-engine 1.3.0 → 1.5.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/index.d.ts CHANGED
@@ -87,6 +87,25 @@ export function parseCSSColor(str: string, outBuf: Float32Array, offset: number)
87
87
  */
88
88
  export function parseDisplayP3ToBuffer(str: string, outBuf: Float32Array, offset: number): number;
89
89
 
90
+ // ============================================================================
91
+ // Formatters (round-trip emit - authoring layer)
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Emits a modern CSS `oklch(L% C H)` string (or with `/ alpha`) from a buffered
96
+ * OKLCH triplet. Precision is chosen for good visual and numeric round-tripping
97
+ * through `parseCSSColor`. Alpha is omitted when not supplied or >= 0.9995.
98
+ * Authoring-time helper (allocates a string); not for per-frame hot paths.
99
+ */
100
+ export function formatOklchCss(buf: Float32Array, off: number, alpha?: number): string;
101
+
102
+ /**
103
+ * Converts an OKLCH triplet to a `#rrggbb` hex string via the accurate sRGB
104
+ * pack path. Round-trips with `parseHexToBuffer` to within 1 LSB per channel.
105
+ * Authoring-time helper (allocates a string); not for per-frame hot paths.
106
+ */
107
+ export function formatHex(buf: Float32Array, off: number): string;
108
+
90
109
  // ============================================================================
91
110
  // Convert (Raw Math)
92
111
  // ============================================================================
@@ -212,6 +231,28 @@ export function packOklchBufferToUint32Fast(
212
231
  alpha?: number
213
232
  ): number;
214
233
 
234
+ /**
235
+ * Dithered sibling of {@link packOklchBufferToUint32}. Applies a threshold-
236
+ * offset dither in gamma-encoded space with a caller-supplied `noise01` value,
237
+ * shared across R/G/B so the dither is a luminance pattern (no chroma speckle).
238
+ * Alpha is undithered.
239
+ *
240
+ * Parity: `noise01 === 0.5` reproduces the byte output of the plain packer
241
+ * exactly (asserted by the test suite).
242
+ *
243
+ * `noise01` is expected in `[0, 1)`. When driven from the shared blue-noise
244
+ * tile, use `getBlueNoise64()[((y & 63) << 6) | (x & 63)] / 256`, which stays
245
+ * strictly below 1 and gives a low-discrepancy sample.
246
+ *
247
+ * @returns 32-bit unsigned integer in little-endian RGBA byte order.
248
+ */
249
+ export function packOklchBufferToUint32Dithered(
250
+ buf: Float32Array,
251
+ offset: number,
252
+ alpha?: number,
253
+ noise01?: number
254
+ ): number;
255
+
215
256
  /**
216
257
  * Batch packer: n OKLCH triplets -> n Uint32 packed colors (stride 1 in dst).
217
258
  * Opt-in 4k sRGB LUT (`useLut=true`) for fast-packer throughput at near-exact accuracy.
@@ -226,6 +267,30 @@ export function packOklchBufferToUint32IntoN(
226
267
  useLut?: boolean
227
268
  ): void;
228
269
 
270
+ /**
271
+ * Dithered batch packer: walks a 64x64 blue-noise tile in row-major order and
272
+ * applies a per-pixel luminance-patterned threshold dither (same noise value
273
+ * across R/G/B; alpha undithered). The tile is torus-indexed
274
+ * (`tile[((y & 63) << 6) | (x & 63)]`) so any `x0`/`y0`/`rowWidth` combination
275
+ * is safe. Zero allocations; no modulo/division in the hot loop.
276
+ *
277
+ * The 4k sRGB transfer LUT is *not* an option here — that LUT stores rounded
278
+ * bytes and would silently degrade the dither to plain rounding. A companion
279
+ * float LUT restoring this axis is a v1.6 candidate.
280
+ */
281
+ export function packOklchBufferToUint32IntoNDithered(
282
+ src: Float32Array,
283
+ offSrc: number,
284
+ dst: Uint32Array,
285
+ offDst: number,
286
+ n: number,
287
+ alpha: number,
288
+ noiseTile: Uint8Array,
289
+ x0: number,
290
+ y0: number,
291
+ rowWidth: number
292
+ ): void;
293
+
229
294
  /**
230
295
  * Encodes an OKLCH triplet to a 32-bit unsigned integer in little-endian RGBA
231
296
  * byte order for a **Display P3** canvas context
@@ -259,6 +324,37 @@ export function packOklchBufferToUint32P3Fast(
259
324
  alpha?: number
260
325
  ): number;
261
326
 
327
+ /**
328
+ * Batch sibling of {@link packOklchBufferToUint32P3}. Packs `n` OKLCH triplets
329
+ * (stride 3) into `n` Uint32 pixels encoded for Display P3. `useLut=true`
330
+ * shares the 4k sRGB transfer LUT (the P3 transfer function is IEC 61966-2-1,
331
+ * identical to sRGB — no separate table is allocated), giving fast-packer
332
+ * throughput at within ~1 LSB of the exact encoder.
333
+ */
334
+ export function packOklchBufferToUint32P3IntoN(
335
+ src: Float32Array,
336
+ offSrc: number,
337
+ dst: Uint32Array,
338
+ offDst: number,
339
+ n: number,
340
+ alpha?: number,
341
+ useLut?: boolean
342
+ ): void;
343
+
344
+ /**
345
+ * Returns the shared 64x64 blue-noise tile as a `Uint8Array(4096)`, lazily
346
+ * decoded from an inlined base64 blob on first call and reused thereafter.
347
+ *
348
+ * Index a pixel via `tile[((y & 63) << 6) | (x & 63)]`. The tile is
349
+ * torus-tileable: repeating it end-to-end on either axis is spectrally
350
+ * seamless. Each byte 0..255 appears exactly 16 times (uniform histogram),
351
+ * so `byte / 256` gives a low-discrepancy sample in `[0, 1)`.
352
+ *
353
+ * The returned reference is shared and must not be mutated; callers that
354
+ * need to modify the values should copy first.
355
+ */
356
+ export function getBlueNoise64(): Uint8Array;
357
+
262
358
  /**
263
359
  * Zero-GC sampler for a baked LUT. Inline `t`-clamp + bitwise-truncated index.
264
360
  *
@@ -281,10 +377,17 @@ export type OklchPackerFn = (buf: Float32Array, offset: number, alpha?: number)
281
377
  * Output bytes are little-endian RGBA - drop into a `Uint32Array` view of
282
378
  * `Canvas ImageData`, or upload as `RGBA / UNSIGNED_BYTE` via `texImage2D`.
283
379
  *
284
- * Stops are evenly distributed: stop _i_ is at `i / (numStops - 1)`. The
285
- * optional `easeFn` warps the parametric position **before** stop selection.
286
- * Easing outputs outside `[0, 1]` are clamped (the LUT is fixed-resolution
287
- * and cannot represent overshoot).
380
+ * **Open mode** (default): stops are evenly distributed stop _i_ at
381
+ * `i / (numStops - 1)`; LUT sample _j_ at `j / (resolution - 1)`. Sample 0
382
+ * lands on stop 0, sample `resolution-1` on the last stop. Overshoot from
383
+ * `easeFn` is clamped to `[0, 1]`.
384
+ *
385
+ * **Closed mode** (`opts.closed = true`, new in v1.5): stops are treated
386
+ * cyclically — the wrap segment runs from `stops[numStops-1]` back to
387
+ * `stops[0]`. Sample _j_ is at `j / resolution` (period spacing, no duplicated
388
+ * endpoint). `easeFn` outputs are wrapped via `t - floor(t)` instead of
389
+ * clamped. Pair with `sampleColorLUTWrapped` on the consumer side (hue wheels,
390
+ * cyclic colorways).
288
391
  *
289
392
  * @param keyframesBuf Contiguous buffer of `[L0, C0, H0, L1, C1, H1, ...]`.
290
393
  * @param numStops Stop count; must be `>= 2`.
@@ -293,6 +396,7 @@ export type OklchPackerFn = (buf: Float32Array, offset: number, alpha?: number)
293
396
  * @param packer Optional packer override. Defaults to the accurate
294
397
  * {@link packOklchBufferToUint32}. Pass
295
398
  * {@link packOklchBufferToUint32Fast} for ~2x bake throughput.
399
+ * @param opts Optional bake options. `{ closed: true }` bakes a cyclic LUT.
296
400
  * @throws If `numStops < 2` or `resolution < 2`.
297
401
  */
298
402
  export function bakeGradientToUint32(
@@ -300,7 +404,8 @@ export function bakeGradientToUint32(
300
404
  numStops: number,
301
405
  resolution?: number,
302
406
  easeFn?: (t: number) => number,
303
- packer?: OklchPackerFn
407
+ packer?: OklchPackerFn,
408
+ opts?: { closed?: boolean }
304
409
  ): Uint32Array;
305
410
 
306
411
  // ============================================================================
package/index.js CHANGED
@@ -5,7 +5,9 @@ export {
5
5
  parseOklabToBuffer,
6
6
  parseOklchToBuffer,
7
7
  parseRgbToBuffer,
8
- parseDisplayP3ToBuffer
8
+ parseDisplayP3ToBuffer,
9
+ formatOklchCss,
10
+ formatHex
9
11
  } from './src/authoring.js';
10
12
 
11
13
  export { bakeGradientToUint32 } from './src/lut.js';
@@ -15,10 +17,14 @@ export {
15
17
  lerpOklchBufferN,
16
18
  packOklchBufferToUint32,
17
19
  packOklchBufferToUint32Fast,
20
+ packOklchBufferToUint32Dithered,
18
21
  packOklchBufferToUint32IntoN,
22
+ packOklchBufferToUint32IntoNDithered,
19
23
  packOklchBufferToUint32P3,
20
24
  packOklchBufferToUint32P3Fast,
21
- sampleColorLUT
25
+ packOklchBufferToUint32P3IntoN,
26
+ sampleColorLUT,
27
+ getBlueNoise64
22
28
  } from './src/runtime.js';
23
29
 
24
30
  export {
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # lite-color-engine - LLM Reference (v1.3)
1
+ # lite-color-engine - LLM Reference (v1.5)
2
2
 
3
3
  ## Core Exports
4
4
 
@@ -6,29 +6,60 @@
6
6
  - `parseCSSColor(str, outBuf, offset)` — Universal parser. Now supports `color(display-p3 r g b / alpha)`
7
7
  - `parseDisplayP3ToBuffer(str, outBuf, offset)` — Direct P3 parser
8
8
 
9
+ ### Formatters (v1.4, authoring-time - allocate a string)
10
+ - `formatOklchCss(buf, off, alpha?)` - OKLCH triplet -> `oklch(L% C H)` (or `/ alpha`); round-trips via parseCSSColor.
11
+ - `formatHex(buf, off)` - OKLCH triplet -> `#rrggbb` via the accurate pack path; round-trips with parseHexToBuffer within 1 LSB.
12
+
9
13
  ### Conversion
10
14
  - `sRgbToOklchBuffer(r, g, b, out, off)`
11
- - `displayP3ToOklchBuffer(r, g, b, out, off)` — New in v1.2
15
+ - `displayP3ToOklchBuffer(r, g, b, out, off)`
12
16
  - `oklchToLinearP3(L, C, H, out)` — Inverse for P3 packing
13
17
 
14
18
  ### Runtime (Hot Path - Zero GC)
15
19
  - `lerpOklchBuffer(...)`
16
20
  - `packOklchBufferToUint32(buf, off, alpha?)` - Accurate sRGB
17
21
  - `packOklchBufferToUint32Fast(...)` - Fast approx
22
+ - `packOklchBufferToUint32Dithered(buf, off, alpha?, noise01?)` — defaults `alpha=1.0, noise01=0.5`, so a 2-arg call is exactly `packOklchBufferToUint32` — **v1.5** dithered sRGB packer.
23
+ Luminance-only threshold dither (same `noise01` across R/G/B, no chroma speckle).
24
+ `noise01 = 0.5` reproduces the plain packer exactly.
18
25
  - `packOklchBufferToUint32P3(buf, off, alpha?)` - Accurate P3 output
19
26
  - `packOklchBufferToUint32P3Fast(...)` - Fast P3 output
20
27
  - `sampleColorLUT(lut, t)` - O(1) lookup into a baked gradient LUT
21
28
 
22
29
  ### Batch kernels (v1.3, for 100k+ particle systems)
23
30
  - `lerpOklchBufferN(a, offA, b, offB, t, out, offOut, n)` - bulk lerp n triplets (stride 3), bit-exact to n scalar calls.
24
- - `packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` - bulk pack n OKLCH -> Uint32 (dst stride 1). `useLut=true` uses the 4k transfer LUT (~4.4x throughput vs accurate, within 1 LSB; Fast-packer speed at near-exact accuracy).
25
- - Note: the batch win comes from `useLut`, not from call-amortization (accurate batch ~= scalar loop in V8; pow-bound).
31
+ - `packOklchBufferToUint32IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` - bulk pack n OKLCH -> Uint32 (dst stride 1). `useLut=true` uses the 4k transfer LUT (~4.4x throughput vs accurate, within 1 LSB).
32
+ - `packOklchBufferToUint32P3IntoN(src, offSrc, dst, offDst, n, alpha?, useLut?)` — **v1.5** P3 batch. `useLut=true` reuses the same `SRGB_LUT` (identical transfer function per IEC 61966-2-1); no separate table is allocated.
33
+ - `packOklchBufferToUint32IntoNDithered(src, offSrc, dst, offDst, n, alpha, noiseTile, x0, y0, rowWidth)` — **v1.5** dithered batch. Walks a 64x64 blue-noise tile in row-major order over destination `(x0, y0)` with `rowWidth`. Zero allocations, no modulo in hot loop. Exact-encoder only (see note below).
34
+ - Note: the batch win comes from `useLut`, not from call-amortization (accurate batch ~= scalar loop in V8; pow-bound). The dithered batch has no `useLut` axis — `SRGB_LUT` stores rounded bytes, which would collapse the dither threshold; a float LUT restoring this is a v1.6 candidate.
35
+
36
+ ### Blue-noise tile (v1.5)
37
+ - `getBlueNoise64()` — Returns the shared 64x64 blue-noise tile as `Uint8Array(4096)`.
38
+ Lazily decoded from an inlined base64 blob on first call (sub-ms), then shared. Void-and-cluster,
39
+ histogram exactly uniform (16x each byte). Index via `tile[((y & 63) << 6) | (x & 63)]`.
40
+ Do not mutate the returned reference. Torus-tileable on both axes.
41
+ - The tile is gated on SPECTRAL properties, not just its SHA-256 fingerprint
42
+ (`test/bluenoise-spectral.test.js`): minority-phase clumpiness < 0.70 across thresholds
43
+ 16..240, that curve symmetric about T=128, radial power for r<=4 below the white-noise
44
+ floor of 1/12, high-band power above it, wrap edges as decorrelated as the interior.
45
+ A uniform histogram does NOT imply blue noise — a linear ramp has a perfectly uniform
46
+ histogram. The 1.5.0 release candidate shipped a tile that passed every histogram /
47
+ fingerprint / banding gate and was still 7x over-clustered above mid-gray (inverted
48
+ Phase 3 selector in the generator). Do not treat the fingerprint as the gate.
26
49
 
27
50
  ### Gamut mapping (subpath: `@zakkster/lite-color-engine/gamut`, NOT hot-path)
28
- - `packOklchBufferToUint32MINDE(buf, off, alpha?)` — MINDE chroma-reduction gamut map. ~30x slower than the core packer; use at LUT-build/authoring time.
51
+ - `gamutMapToSrgbBuffer(inBuf, off, outBuf, offOut)` — MINDE chroma-reduction gamut map (scalar).
52
+ - `gamutMapToSrgbBufferN(inBuf, off, outBuf, offOut, n)` — **v1.5** batch MINDE. Bit-for-bit identical to n scalar calls; for authoring / LUT-build bulk mapping.
53
+ - `packOklchBufferToUint32MINDE(buf, off, alpha?)` — MINDE + pack. ~30x slower than the core packer; LUT-build/authoring time only.
54
+
55
+ ### LUT (Gradient Baking)
56
+ - `bakeGradientToUint32(keyframesBuf, numStops, resolution?, easeFn?, packer?, opts?)` — accepts any of the above packers.
57
+ - **v1.5:** trailing `opts` object. `{ closed: true }` bakes a cyclic LUT: sample positions `i / resolution` (period, no duplicated endpoint), wrap segment last->first, ease outputs period-wrapped via `t - floor(t)`. Pair with `sampleColorLUTWrapped` on the consumer side (hue wheels, cyclic colorways).
58
+ - Open mode (default) is byte-identical to v1.4.
59
+
60
+ ## Session Gate (v1.5)
29
61
 
30
- ### LUT
31
- - `bakeGradientToUint32(..., packer?)` — Accepts any of the above packers
62
+ - `npm run preflight` — mandatory before feature work. Fetches the published tarball via `npm pack` and hash-compares against HEAD. Hard-fails on code drift (`index.js`, `index.d.ts`, `src/**`, `package.json`); warns for doc drift.
32
63
 
33
64
  ## Key Principles
34
65
  - All hot-path functions are allocation-free
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-color-engine",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
5
5
  "description": "Zero-GC, data-oriented OKLCH color engine and WebGL/Canvas buffer pipeline.",
6
6
  "type": "module",
@@ -60,13 +60,14 @@
60
60
  ],
61
61
  "scripts": {
62
62
  "test": "vitest run",
63
- "bench": "node bench/benchmark.mjs"
63
+ "bench": "node bench/benchmark.mjs",
64
+ "preflight": "node tools/preflight.mjs"
64
65
  },
65
66
  "dependencies": {
66
67
  "@zakkster/lite-lerp": "^1.0.0"
67
68
  },
68
69
  "devDependencies": {
69
- "vitest": "^1.0.0"
70
+ "vitest": "^4.1.10"
70
71
  },
71
72
  "license": "MIT",
72
73
  "homepage": "https://github.com/PeshoVurtoleta/lite-color-engine#readme",
package/src/Gamut.d.ts CHANGED
@@ -19,6 +19,24 @@ export function gamutMapToSrgbBuffer(
19
19
  outOffset: number
20
20
  ): void;
21
21
 
22
+ /**
23
+ * Batch sibling of `gamutMapToSrgbBuffer`. Maps `n` OKLCH triplets (stride 3)
24
+ * from `inBuf` into `outBuf` at the equivalent stride, using the same CSS
25
+ * Color 4 MINDE bisection. Bit-for-bit identical to `n` scalar calls; the
26
+ * batch exists to amortize call overhead for authoring / LUT-building large
27
+ * color runs (hueforge P3 exporters, studio bake paths).
28
+ *
29
+ * Setup-time / bake-time convenience — MINDE stays out of the per-frame hot
30
+ * path (~30x slower than the core packer). Zero allocations.
31
+ */
32
+ export function gamutMapToSrgbBufferN(
33
+ inBuf: Float32Array,
34
+ inOffset: number,
35
+ outBuf: Float32Array,
36
+ outOffset: number,
37
+ n: number
38
+ ): void;
39
+
22
40
  /**
23
41
  * Accurate sibling of `packOklchBufferToUint32`. Runs MINDE gamut mapping
24
42
  * before packing, eliminating the hue-shift artifacts of the hard channel
package/src/Gamut.js CHANGED
@@ -189,6 +189,39 @@ export function gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset) {
189
189
  }
190
190
 
191
191
 
192
+ // -----------------------------------------------------------------------------
193
+ // gamutMapToSrgbBufferN
194
+ // -----------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Batch sibling of {@link gamutMapToSrgbBuffer}. Maps `n` OKLCH triplets
198
+ * (stride 3) from `inBuf` into `outBuf` at the equivalent stride, using the
199
+ * same CSS Color 4 MINDE bisection as the scalar path. Bit-for-bit identical
200
+ * to calling {@link gamutMapToSrgbBuffer} `n` times; the batch exists to
201
+ * amortize call overhead when authoring / LUT-building large color runs
202
+ * (hueforge P3 exporters, studio bake paths) that today loop the scalar.
203
+ *
204
+ * MINDE is ~30x slower than the core packer and stays out of the per-frame
205
+ * hot path — this batch is a setup-time / bake-time convenience. Zero
206
+ * allocations (reuses the module-level scratch). In-place is safe when
207
+ * `inBuf === outBuf` at aligned offsets: each iteration reads its three
208
+ * source lanes into `_rgb`/`_clipLch` scratch before writing back.
209
+ *
210
+ * @param {Float32Array} inBuf - Source OKLCH buffer (stride 3)
211
+ * @param {number} inOffset - Base offset of the first triplet in inBuf
212
+ * @param {Float32Array} outBuf - Destination OKLCH buffer (stride 3)
213
+ * @param {number} outOffset - Base offset of the first triplet in outBuf
214
+ * @param {number} n - Number of triplets to map (n <= 0 is a no-op)
215
+ * @returns {void}
216
+ */
217
+ export function gamutMapToSrgbBufferN(inBuf, inOffset, outBuf, outOffset, n) {
218
+ if (n <= 0) return;
219
+ for (let i = 0; i < n; i++) {
220
+ gamutMapToSrgbBuffer(inBuf, inOffset + i * 3, outBuf, outOffset + i * 3);
221
+ }
222
+ }
223
+
224
+
192
225
  // -----------------------------------------------------------------------------
193
226
  // packOklchBufferToUint32MINDE
194
227
  // -----------------------------------------------------------------------------
package/src/authoring.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { sRgbToOklchBuffer, displayP3ToOklchBuffer } from './convert.js';
2
+ import { packOklchBufferToUint32 } from './runtime.js';
2
3
 
3
4
  const RAD_TO_DEG = 180 / Math.PI;
4
5
 
@@ -80,7 +81,7 @@ const NAMED_COLORS = {
80
81
  darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b',
81
82
  darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc',
82
83
  darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', darkslateblue: '#483d8b',
83
- darkslategray: '#2f4f4f', darkslateggrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
84
+ darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
84
85
  deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
85
86
  dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
86
87
  fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700',
@@ -91,12 +92,12 @@ const NAMED_COLORS = {
91
92
  lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3',
92
93
  lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a',
93
94
  lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899',
94
- lightsteelblue: '#b0e0e6', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
95
+ lightsteelblue: '#b0c4de', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
95
96
  linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
96
97
  mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
97
98
  mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585',
98
99
  midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
99
- navajawhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000',
100
+ navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000',
100
101
  olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6',
101
102
  palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
102
103
  papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb',
@@ -301,3 +302,59 @@ export const parseCSSColor = (str, outBuf, offset) => {
301
302
 
302
303
  throw new Error(`lite-color-engine: Unsupported color format "${str}".`);
303
304
  };
305
+
306
+ // ============================================================================
307
+ // 6. FORMATTERS (round-trip emit - authoring layer, string allocation is fine)
308
+ // ============================================================================
309
+
310
+ /**
311
+ * Emits a modern CSS `oklch(...)` string from an OKLCH buffer triplet.
312
+ * Suitable for exports, CSS custom properties, gradient studios, telemetry, etc.
313
+ *
314
+ * Lightness is emitted as a percentage (e.g. `60.0%`), chroma and hue with
315
+ * enough precision to round-trip through `parseOklchToBuffer`/`parseCSSColor`.
316
+ * Alpha is included only when supplied and below 1.
317
+ *
318
+ * This is an authoring-time helper: it allocates a string and is not intended
319
+ * for per-frame hot paths.
320
+ *
321
+ * @param {Float32Array} buf - Buffer containing [L, C, H]
322
+ * @param {number} off - Offset of the triplet
323
+ * @param {number} [alpha] - Optional alpha [0, 1]. Omitted from output when >= 0.9995 or not supplied.
324
+ * @returns {string} e.g. "oklch(60.0% 0.150 250.0)" or with " / 0.800"
325
+ */
326
+ export const formatOklchCss = (buf, off, alpha) => {
327
+ const l = buf[off] * 100;
328
+ const c = buf[off + 1];
329
+ const h = buf[off + 2];
330
+ let s = `oklch(${l.toFixed(1)}% ${c.toFixed(3)} ${h.toFixed(1)})`;
331
+ if (alpha != null && alpha < 0.9995) {
332
+ s += ` / ${alpha.toFixed(3)}`;
333
+ }
334
+ return s;
335
+ };
336
+
337
+ /**
338
+ * Converts an OKLCH buffer triplet to a 6-digit `#rrggbb` hex string (no alpha).
339
+ *
340
+ * Uses the accurate sRGB transfer + pack path, so it round-trips with
341
+ * `parseHexToBuffer` + `packOklchBufferToUint32` to within 1 LSB per channel
342
+ * (the accurate packer's rounding tolerance). Out-of-sRGB-gamut input is hard
343
+ * clamped, matching the packer.
344
+ *
345
+ * Authoring-time helper: allocates a string, not for per-frame hot paths.
346
+ *
347
+ * @param {Float32Array} buf - Buffer containing [L, C, H]
348
+ * @param {number} off - Offset of the triplet
349
+ * @returns {string} e.g. "#ff0000"
350
+ */
351
+ export const formatHex = (buf, off) => {
352
+ const u = packOklchBufferToUint32(buf, off);
353
+ const r = u & 0xff;
354
+ const g = (u >>> 8) & 0xff;
355
+ const b = (u >>> 16) & 0xff;
356
+ return '#' +
357
+ r.toString(16).padStart(2, '0') +
358
+ g.toString(16).padStart(2, '0') +
359
+ b.toString(16).padStart(2, '0');
360
+ };
package/src/lut.js CHANGED
@@ -6,11 +6,17 @@ import { lerpOklchBuffer, packOklchBufferToUint32 } from './runtime.js';
6
6
  * The output is in little-endian RGBA byte order — drop it directly into a
7
7
  * `Uint32Array` view of `Canvas ImageData`, or `texImage2D` with `RGBA / UNSIGNED_BYTE`.
8
8
  *
9
- * Stops are evenly spaced across the gradient (stop _i_ is at `i / (numStops - 1)`).
10
- * The optional `easeFn` warps the parametric position **before** stop selection,
11
- * letting you bias which stop dominates which range of the LUT. Easing outputs
12
- * outside `[0, 1]` are clamped (the LUT is fixed-resolution and cannot
13
- * represent overshoot).
9
+ * **Open mode (default):** stop _i_ is at `i / (numStops - 1)`; LUT sample _j_
10
+ * is at `j / (resolution - 1)`. Sample 0 lands on stop 0, sample `resolution-1`
11
+ * lands on the last stop. Overshoot from `easeFn` is clamped to `[0, 1]`.
12
+ *
13
+ * **Closed mode** (`opts.closed = true`, new in v1.5): stops are treated
14
+ * cyclically — the wrap segment runs from `stops[numStops-1]` back to
15
+ * `stops[0]`. Sample _j_ is at `j / resolution` (period spacing, no duplicated
16
+ * endpoint), so `sample[resolution-1]` sits just before wrapping back to
17
+ * sample 0. This is the LUT baked by hue-wheel gradients / cyclic colorways —
18
+ * pair it with `sampleColorLUTWrapped` on the consumer side. `easeFn` outputs
19
+ * in closed mode are wrapped via `t - floor(t)` (period) instead of clamped.
14
20
  *
15
21
  * @param {Float32Array} keyframesBuf - Contiguous buffer of `[L0, C0, H0, L1, C1, H1, ...]`
16
22
  * @param {number} numStops - Count of color stops in the buffer (must be >= 2)
@@ -19,6 +25,8 @@ import { lerpOklchBuffer, packOklchBufferToUint32 } from './runtime.js';
19
25
  * @param {(buf: Float32Array, offset: number, alpha?: number) => number} [packer]
20
26
  * Optional packer override. Defaults to the accurate `packOklchBufferToUint32`.
21
27
  * Pass `packOklchBufferToUint32Fast` for ~2x throughput at the cost of round-trip accuracy.
28
+ * @param {{ closed?: boolean }} [opts]
29
+ * Optional bake options. `closed: true` bakes a cyclic LUT (see above).
22
30
  * @returns {Uint32Array} LUT of 32-bit packed colors
23
31
  * @throws If `numStops < 2` or `resolution < 2`
24
32
  */
@@ -27,7 +35,8 @@ export const bakeGradientToUint32 = (
27
35
  numStops,
28
36
  resolution = 256,
29
37
  easeFn,
30
- packer = packOklchBufferToUint32
38
+ packer = packOklchBufferToUint32,
39
+ opts
31
40
  ) => {
32
41
  if (numStops < 2) {
33
42
  throw new Error('lite-color-engine: bakeGradientToUint32 requires numStops >= 2');
@@ -36,29 +45,41 @@ export const bakeGradientToUint32 = (
36
45
  throw new Error('lite-color-engine: bakeGradientToUint32 requires resolution >= 2');
37
46
  }
38
47
 
48
+ const closed = opts != null && opts.closed === true;
39
49
  const outLUT = new Uint32Array(resolution);
40
50
  const tempOklch = new Float32Array(3);
41
51
 
42
- const step = 1 / (resolution - 1);
43
- const last = numStops - 1;
52
+ // Open: N-1 segments, samples at i/(res-1), t clamped to [0,1].
53
+ // Closed: N segments (last wraps last->first), samples at i/res, t period-wrapped.
54
+ const step = closed ? 1 / resolution : 1 / (resolution - 1);
55
+ const scale = closed ? numStops : (numStops - 1);
56
+ const maxIndex = closed ? (numStops - 1) : (numStops - 2);
57
+ const wrapIndex = numStops - 1; // only meaningful in closed mode
44
58
  const hasEase = typeof easeFn === 'function';
45
59
 
46
60
  for (let i = 0; i < resolution; i++) {
47
61
  const rawT = i * step;
48
62
  let t = hasEase ? easeFn(rawT) : rawT;
49
63
 
50
- // LUTs are fixed-resolution; overshoot/undershoot is undefined and
51
- // would either NaN-poison the buffer or extrapolate silently.
52
- if (t < 0) t = 0;
53
- else if (t > 1) t = 1;
64
+ if (closed) {
65
+ // Period wrap: `t - floor(t)` maps R to [0, 1). Handles ease overshoot
66
+ // and negative outputs uniformly; the cyclic contract has no clamp.
67
+ t = t - Math.floor(t);
68
+ } else {
69
+ // LUTs are fixed-resolution; overshoot/undershoot is undefined and
70
+ // would either NaN-poison the buffer or extrapolate silently.
71
+ if (t < 0) t = 0;
72
+ else if (t > 1) t = 1;
73
+ }
54
74
 
55
- const scaledT = t * last;
56
- let index = scaledT | 0; // bitwise fast-floor for positive values
57
- if (index >= last) index = last - 1;
75
+ const scaledT = t * scale;
76
+ let index = scaledT | 0; // bitwise fast-floor for non-negative values
77
+ if (index > maxIndex) index = maxIndex;
58
78
 
59
79
  const localT = scaledT - index;
60
80
  const offsetA = index * 3;
61
- const offsetB = offsetA + 3;
81
+ // Closed mode: the wrap segment (last -> first) reads keyframe 0 as B.
82
+ const offsetB = (closed && index === wrapIndex) ? 0 : offsetA + 3;
62
83
 
63
84
  lerpOklchBuffer(keyframesBuf, offsetA, keyframesBuf, offsetB, localT, tempOklch, 0);
64
85
  outLUT[i] = packer(tempOklch, 0, 1.0);