@zakkster/lite-gradient-studio 1.1.0 → 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 +231 -0
- package/README.md +100 -0
- package/llms.txt +42 -0
- package/package.json +4 -4
- package/src/bake.js +36 -0
- package/src/index.d.ts +83 -1
- package/src/index.js +1 -0
- package/src/mesh.js +274 -74
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,237 @@ All notable changes to `@zakkster/lite-gradient-studio` are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
This library follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.0] — 2026-07-14 "Tiles + Dither"
|
|
9
|
+
|
|
10
|
+
The **toroidal-mesh + dither release**. Both roadmap tracks (D1-D6 wrap,
|
|
11
|
+
D7-D8 dither) land together.
|
|
12
|
+
|
|
13
|
+
> Originally cut as two patches, `1.2.0` (wrap) and `1.2.1` (dither), on the
|
|
14
|
+
> assumption that 1.2.0 had shipped. It had not — npm's latest is 1.1.0 — so a
|
|
15
|
+
> patch bump on top of an unpublished minor would have invented a version that
|
|
16
|
+
> never existed. Folded into a single minor.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Wrap (D1-D6)
|
|
21
|
+
|
|
22
|
+
The **toroidal-mesh release**. `MeshGradient` becomes cylindrical or
|
|
23
|
+
toroidal on demand, with a real C¹ seam in cubic mode — the flagship
|
|
24
|
+
feature: read real neighbours across the boundary via modulo indexing
|
|
25
|
+
instead of clamping to duplicated endpoints.
|
|
26
|
+
|
|
27
|
+
### Added — Structural wrap on the constructor
|
|
28
|
+
|
|
29
|
+
`new MeshGradient(cols, rows, stops, { wrapX, wrapY })`. Both flags are
|
|
30
|
+
optional, default `false`, and independent (torus = both on; cylinder =
|
|
31
|
+
one on). Wrap is stored on the instance as `readonly wrapX` / `readonly
|
|
32
|
+
wrapY` for downstream consumers to branch on.
|
|
33
|
+
|
|
34
|
+
- **UV period.** On a wrapped axis the cell count is `cols`, not
|
|
35
|
+
`cols - 1`. Default control-point positions become `col / cols`.
|
|
36
|
+
- **`sampleAt` (bilinear + smooth).** Wrapped axis wraps the coord via
|
|
37
|
+
`u - Math.floor(u)`, then reads the last-cell neighbour with `(col + 1)
|
|
38
|
+
% cols` — so `sampleAt(1, v)` is exactly `sampleAt(0, v)`, any float
|
|
39
|
+
is a valid position (raw accumulating animation phase, negatives,
|
|
40
|
+
arbitrary magnitudes).
|
|
41
|
+
- **`sampleAt` cubic — C¹ continuity across the seam.** The whole
|
|
42
|
+
reason wrap exists: `_sampleAtCubic` and `_cubicRow` use modulo
|
|
43
|
+
neighbour indexing on wrapped axes (`((col - 1) % cols + cols) % cols`
|
|
44
|
+
and `(col + 2) % cols`). Catmull-Rom now reads real neighbours across
|
|
45
|
+
the boundary, giving a genuinely smooth seam instead of a spline that
|
|
46
|
+
degrades toward bilinear at the edge. Bilinear and smooth get
|
|
47
|
+
seamlessness from the wrap coord alone; cubic is where wrap earns
|
|
48
|
+
"exists in no other library."
|
|
49
|
+
- **`rasterizeTo` period sampling.** On wrapped axes, `u = x / width`
|
|
50
|
+
(drops the `- 1` divisor). Pixel column 0 of the "next tile" lands
|
|
51
|
+
exactly at `u ≡ 0` — no duplicated edge column, so `drawImage`-based
|
|
52
|
+
tiling of the rasterized output butts perfectly.
|
|
53
|
+
- **`rasterizeDeformedTo` fails loudly on wrap.** Throws with
|
|
54
|
+
`err.code = 'WRAP_DEFORMED_UNSUPPORTED'` when called on a wrapped
|
|
55
|
+
mesh. Deformed + wrap needs ghost quads that cross the seam (Newton
|
|
56
|
+
solve against wrapped corner positions) — real work deferred to v1.3.
|
|
57
|
+
Silent seamed output would be worse than the error; consumer code can
|
|
58
|
+
branch on the code string instead of matching messages.
|
|
59
|
+
- **`defaultMeshColor` — wrap-aware defaults.** Trailing `wrapX` /
|
|
60
|
+
`wrapY` args (backward-compat: absent → v1.1.0 output byte-identical).
|
|
61
|
+
When `wrapX`, hue advances a uniform `360/cols` per column (no
|
|
62
|
+
aperiodic compression at the wrap boundary). When `wrapY`, the row
|
|
63
|
+
drift becomes sinusoidal and L switches to `cos(2π · rT)` so
|
|
64
|
+
top and bottom rows agree.
|
|
65
|
+
|
|
66
|
+
### Guarantees
|
|
67
|
+
|
|
68
|
+
- **Byte-parity on the non-wrap path.** All 218 pre-existing tests pass
|
|
69
|
+
unmodified. The alloc test file gains three new wrapped-rasterize
|
|
70
|
+
invariants (all under the same 128 KB / 100-frame ceiling). Every
|
|
71
|
+
non-wrap code path — constructor with no opts, `sampleAt` on a
|
|
72
|
+
non-wrapped mesh, `rasterizeTo` on a non-wrapped mesh — walks the
|
|
73
|
+
exact same v1.1.0 code, byte for byte.
|
|
74
|
+
- **Empirical wrap-on ceilings (this container, node 22):**
|
|
75
|
+
|
|
76
|
+
| path | v1.1.0 baseline | v1.2.0 wrapped | overhead |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `sampleAt` 5×5 smooth | 5.2M ops/s | 5.0M wrapX / 4.7M torus | 4–10% |
|
|
79
|
+
| `sampleAt` 5×5 cubic | 1.9M ops/s | 2.0M wrapX / 2.0M torus | within noise |
|
|
80
|
+
| `rasterizeTo` 5×5 → 256² | 2.1M px/s | 2.3M px/s wrapped | JIT ties or wins |
|
|
81
|
+
| `rasterizeTo` 5×5 cubic → 256² | (not measured) | 2.3M px/s wrapX | ceiling set |
|
|
82
|
+
|
|
83
|
+
Wrap paths came in on parity or a hair faster; the branch structure
|
|
84
|
+
turns out to be slightly cleaner for the JIT than the non-wrap
|
|
85
|
+
interior-clamp branches. Empirical, not prior-based — the ceilings
|
|
86
|
+
above are the number to beat in T3.
|
|
87
|
+
|
|
88
|
+
### Peer bumps
|
|
89
|
+
|
|
90
|
+
- `@zakkster/lite-color-engine`: `^1.0.0 → ^1.5.0`. T3's dither work
|
|
91
|
+
will delegate to engine v1.5's `getBlueNoise64` + dithered packers.
|
|
92
|
+
- `@zakkster/lite-gradient`: `^1.1.0 → ^1.2.0`. Picks up `Gradient`'s
|
|
93
|
+
new `closed: true` for downstream `formatCssConic` work.
|
|
94
|
+
|
|
95
|
+
### Notes
|
|
96
|
+
|
|
97
|
+
- 31 new wrap tests in `test/mesh-wrap.test.js` covering: D1 (opts +
|
|
98
|
+
guards + cols=2 wrapped legality), D2 (period spacing, seam identity
|
|
99
|
+
in bilinear/smooth, raw-phase and negative-u sampling, tile-equality
|
|
100
|
+
across 21 sample points, non-wrap axis retains clamp), D3 (cubic
|
|
101
|
+
seam identity, C¹ derivative test via central differences,
|
|
102
|
+
real-neighbour indexing verified against a deliberately non-uniform
|
|
103
|
+
L mesh), D4 (rasterize period sampling, non-wrap y retains closed
|
|
104
|
+
interval), D5 (WRAP_DEFORMED_UNSUPPORTED on wrapX / wrapY / torus,
|
|
105
|
+
non-wrap deformed still works), D6 (byte-parity of the four-arg
|
|
106
|
+
form, periodic hue step and L symmetry, integration with
|
|
107
|
+
`MeshGradient` defaults).
|
|
108
|
+
- Wrap suite: 252 green (218 pre-existing + 31 wrap + 3 wrap-alloc
|
|
109
|
+
invariants) at the point wrap landed; 271 with dither folded in.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Known / measured — the allocation gate is a leak gate
|
|
114
|
+
|
|
115
|
+
`test/allocation.test.js` asserts things like *"MeshGradient.rasterizeTo x 100 frames
|
|
116
|
+
at 128x128 stays under 128 KB"*, measured as `gc(); gc(); heapUsed` before and after.
|
|
117
|
+
That is a **leak** gate. It measures RETAINED memory, and it is structurally blind to
|
|
118
|
+
short-lived garbage — a value allocated and dropped inside the loop is scavenged
|
|
119
|
+
before the second sample and never appears in the delta.
|
|
120
|
+
|
|
121
|
+
`rasterizeTo` does allocate, and the gate cannot see it. Measured by counting GC
|
|
122
|
+
events over a fixed wall-clock budget, against a zero-alloc arithmetic control and a
|
|
123
|
+
one-object-per-pixel control in the same process:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
zero-alloc floor (arithmetic) 1 GC / 6 s
|
|
127
|
+
allocating ceiling (1 obj/pixel) 3587 GC / 6 s
|
|
128
|
+
rasterizeTo bilinear, no dither 72 GC / 6 s <- not the floor
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The source is not a `new` anywhere in the loop — there isn't one. It is
|
|
132
|
+
`const tmp = { l: 0, c: 0, h: 0, a: 1 }`. **V8 removed double-field unboxing in 9.x**,
|
|
133
|
+
so every write of a non-Smi double to an object property boxes a `HeapNumber` — and
|
|
134
|
+
`sampleAt` writes four of them per pixel, 16,384 times per raster. Isolated:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
write 4 doubles into an OBJECT, 16384x -> 3519 GC
|
|
138
|
+
write 4 doubles into a Float64Array, 16384x -> 497 GC
|
|
139
|
+
write 4 Smis into an OBJECT, 16384x -> 496 GC
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Cost, in the only unit that matters: 0.37%–0.95% of wall time** in a synthetic loop
|
|
143
|
+
running 150 rasters/sec; roughly half that at a realistic 60/sec. It is real, it is
|
|
144
|
+
measurable, and it does not matter.
|
|
145
|
+
|
|
146
|
+
Not fixed, deliberately. The only way to remove it is a `Float64Array` scratch, which
|
|
147
|
+
means changing `sampleAt(u, v, out, mode)`'s public object-out contract or duplicating
|
|
148
|
+
the sampler. That is a bad trade for half a percent. Logged with the number so the
|
|
149
|
+
call is informed.
|
|
150
|
+
|
|
151
|
+
What *should* change is the gate's name and its comment, which currently imply it
|
|
152
|
+
proves zero-GC. It proves no leak. Those are different claims, and the ecosystem has
|
|
153
|
+
now made this mistake in three packages.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Dither (D7-D8)
|
|
158
|
+
|
|
159
|
+
The dither track (D7/D8). One flag on `rasterizeTo` and one scalar-arg
|
|
160
|
+
packer wrapper. `dither: false` (or absent) is byte-identical to the
|
|
161
|
+
undithered path — verified, not asserted.
|
|
162
|
+
|
|
163
|
+
### Added — `opts.dither: true` on `MeshGradient.rasterizeTo`
|
|
164
|
+
|
|
165
|
+
Delegates to `@zakkster/lite-color-engine >= 1.5.0`'s
|
|
166
|
+
`packOklchBufferToUint32Dithered` + `getBlueNoise64` — the roadmap's D7
|
|
167
|
+
already ratified the engine as the noise-source owner, so studio
|
|
168
|
+
consumes and does not duplicate:
|
|
169
|
+
|
|
170
|
+
- **Per-pixel tile lookup** — `tile[((y & 63) << 6) | (x & 63)]`. Pure
|
|
171
|
+
bit ops (torus wrap via mask, row stride via shift), zero-GC trivially.
|
|
172
|
+
- **Same noise value shared R/G/B at a pixel** — luminance-patterned
|
|
173
|
+
dither, no chroma speckle. Verified by a test that rasterizes a
|
|
174
|
+
chroma-free mesh with dither on and asserts R === G === B per pixel.
|
|
175
|
+
- **Alpha undithered.** Verified against undithered α on a distinctive
|
|
176
|
+
α mesh.
|
|
177
|
+
- **`noise01 = 0.5` reproduces the plain packer exactly** — the identity
|
|
178
|
+
anchor from D8, verified across seven representative OKLCH triplets
|
|
179
|
+
covering different gamma-encode regions and alpha values.
|
|
180
|
+
- **`dither: false` or absent → byte-identical to v1.2.0.** The branch
|
|
181
|
+
is resolved once per rasterize call, two loop bodies — no per-pixel
|
|
182
|
+
branch cost on the undithered path. Six regression tests assert this
|
|
183
|
+
across bilinear, smooth, cubic, wrapped, and `dither: false` vs
|
|
184
|
+
absent-opt.
|
|
185
|
+
|
|
186
|
+
### Added — `packOklchSingleDithered(l, c, h, alpha, noise01)`
|
|
187
|
+
|
|
188
|
+
New export from `./bake.js`. Scalar-arg convenience wrapper around the
|
|
189
|
+
engine's `packOklchBufferToUint32Dithered` using the same
|
|
190
|
+
`Float32Array(3)` scratch as `packOklchSingle` — no additional module-
|
|
191
|
+
level allocation, safe to alternate between plain and dithered calls
|
|
192
|
+
in the rasterize inner loop.
|
|
193
|
+
|
|
194
|
+
### Empirical dither ceilings (node 22, container)
|
|
195
|
+
|
|
196
|
+
| path | undithered | dithered | overhead |
|
|
197
|
+
|---|---|---|---|
|
|
198
|
+
| `rasterizeTo` 5×5 smooth → 256² | 2.1M px/s | 1.6M px/s | ~24% |
|
|
199
|
+
| `rasterizeTo` 5×5 wrapX+smooth | 2.5M px/s | 1.9M px/s | ~24% |
|
|
200
|
+
| `rasterizeTo` 5×5 cubic | (v1.2.0 gate) | 1.7M px/s | ceiling set |
|
|
201
|
+
|
|
202
|
+
The 24% overhead pays for one blue-noise tile lookup + a threshold-offset
|
|
203
|
+
gamma-encode round per pixel (the engine's dithered packer inlines the
|
|
204
|
+
sRGB gamma-encode inline instead of calling out to `linearToSrgbByte`,
|
|
205
|
+
which is why the overhead is small).
|
|
206
|
+
|
|
207
|
+
### Test count
|
|
208
|
+
|
|
209
|
+
+15 dither tests in `test/mesh-dither.test.js`:
|
|
210
|
+
- Off-flag byte parity: 6 tests (bilinear, smooth, cubic, wrapped,
|
|
211
|
+
`dither: false` explicit, bare-opts).
|
|
212
|
+
- `noise01 = 0.5` identity anchor: 1 test, 7 OKLCH samples.
|
|
213
|
+
- Determinism: 2 tests (plain, wrap+dither composition).
|
|
214
|
+
- Bounded deviation ±1 per channel: 2 tests (typical mesh, shallow-ramp).
|
|
215
|
+
- Contract properties: 2 tests (alpha never dithered, R === G === B on
|
|
216
|
+
chroma-free mesh).
|
|
217
|
+
- Run-length shortening on shallow ramp: 1 test.
|
|
218
|
+
- Zero-GC under `--expose-gc`: 1 test (128 KB / 100 frames).
|
|
219
|
+
|
|
220
|
+
+2 dither-alloc invariants in `test/allocation.test.js` (plain dither,
|
|
221
|
+
wrapX+dither).
|
|
222
|
+
|
|
223
|
+
Total suite: **269/269 green.** Byte-parity gate on the 218 v1.1.0
|
|
224
|
+
tests still holds unmodified.
|
|
225
|
+
|
|
226
|
+
### Notes
|
|
227
|
+
|
|
228
|
+
- `rasterizeDeformedTo` still throws `WRAP_DEFORMED_UNSUPPORTED` on
|
|
229
|
+
wrapped meshes (unchanged from v1.2.0). Dither on `rasterizeDeformedTo`
|
|
230
|
+
silently ignores the flag — deformed + dither is scheduled to land
|
|
231
|
+
alongside the v1.3 ghost-quad seam work, so consumers on 1.2.x
|
|
232
|
+
should treat this as "dither is rasterizeTo only."
|
|
233
|
+
- The engine's blue-noise tile is decoded once at first use (~sub-ms
|
|
234
|
+
from a base64 blob into a shared `Uint8Array(4096)`). Subsequent
|
|
235
|
+
calls return the same reference — the alloc test's warm-up phase
|
|
236
|
+
triggers the one-time decode before the timed loop.
|
|
237
|
+
- No peer bumps — engine v1.5.0 was already the floor from v1.2.0.
|
|
238
|
+
|
|
8
239
|
## [1.1.0] — 2026-07-03
|
|
9
240
|
|
|
10
241
|
### Added — Monochrome mesh
|
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
[](https://bundlephobia.com/result?p=@zakkster/lite-gradient-studio)
|
|
9
9
|
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
10
10
|
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
11
|
+

|
|
11
12
|

|
|
12
13
|
[](https://opensource.org/licenses/MIT)
|
|
13
14
|
|
|
@@ -284,6 +285,105 @@ Direct format functions are also exported (`toCss1d`, `toScss1d`, `toTailwind1d`
|
|
|
284
285
|
| `sampleLut(lut, t)` | Read a baked LUT at `t ∈ [0, 1]`. Returns the packed ARGB `Uint32` at that index. |
|
|
285
286
|
| `flattenStopsToBuffer(gradient, out?)` | Write OKLCH stops to a Float32Array as `[L, C, H, L, C, H, ...]` (GPU-upload friendly). Optional `out` buffer reused if large enough. |
|
|
286
287
|
|
|
288
|
+
## Toroidal mesh (v1.2.0 "Tiles")
|
|
289
|
+
|
|
290
|
+
`MeshGradient` gains a fourth constructor argument:
|
|
291
|
+
|
|
292
|
+
```js
|
|
293
|
+
new MeshGradient(cols, rows, stops, { wrapX: true, wrapY: true });
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Both flags default to `false` and are independent — turn on `wrapX` for a
|
|
297
|
+
cylinder, both for a torus. Wrap is structural: it changes the UV period,
|
|
298
|
+
the cell count, and the default control-point positions in one atomic
|
|
299
|
+
step. The instance carries `readonly wrapX` and `readonly wrapY` fields
|
|
300
|
+
for downstream consumers to branch on.
|
|
301
|
+
|
|
302
|
+
**The flagship:** cubic mode has real **C¹ continuity across the seam**.
|
|
303
|
+
`_sampleAtCubic` reads real neighbours across the wrap boundary via
|
|
304
|
+
modulo indexing instead of clamping to a duplicated endpoint. Bilinear
|
|
305
|
+
and smooth get seamlessness from the wrap coord alone; cubic is where
|
|
306
|
+
wrap earns "exists in no other library."
|
|
307
|
+
|
|
308
|
+
Sampling accepts any float on wrapped axes — a raw animation phase, a
|
|
309
|
+
negative, arbitrary magnitude:
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
let phase = 0;
|
|
313
|
+
function frame(dt) {
|
|
314
|
+
phase += dt * 0.0002;
|
|
315
|
+
mesh.sampleAt(u + phase, v, out, 'cubic'); // no `phase % 1` needed
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Tiled rasterization.** On wrapped axes, `rasterizeTo` samples the
|
|
320
|
+
period (`x / width`, no `- 1` divisor), so pixel column 0 of the "next
|
|
321
|
+
tile" lands exactly at `u ≡ 0` — `drawImage`-based tile scrolling butts
|
|
322
|
+
perfectly with no duplicated edge column.
|
|
323
|
+
|
|
324
|
+
**Deformed + wrap fails loudly.** `rasterizeDeformedTo` throws with
|
|
325
|
+
`err.code = 'WRAP_DEFORMED_UNSUPPORTED'` when called on a wrapped mesh.
|
|
326
|
+
Ghost-quad seam crossing (Newton solve against wrapped corner positions)
|
|
327
|
+
is real work, deferred to v1.3. Consumer code can branch on the code
|
|
328
|
+
string cleanly rather than string-matching messages.
|
|
329
|
+
|
|
330
|
+
**Wrap-aware defaults.** `defaultMeshColor(col, row, cols, rows, wrapX?,
|
|
331
|
+
wrapY?)` gains trailing optional args. When set, the aperiodic
|
|
332
|
+
`240 + 120·cT` hue sweep is swapped for a uniform `360/cols` step, and
|
|
333
|
+
row-driven L switches to `cos(2π · rT)` so the top and bottom rows
|
|
334
|
+
agree. Four-arg calls are byte-identical to v1.1.0.
|
|
335
|
+
|
|
336
|
+
**Byte-parity guarantee.** The non-wrap path is untouched — 218
|
|
337
|
+
pre-existing tests pass unmodified, benches within noise of v1.1.0.
|
|
338
|
+
|
|
339
|
+
**Cascade note.** Blue-noise dithering (D7/D8 in the Tiles roadmap) lands
|
|
340
|
+
in v1.2.0 with its full gate — see below.
|
|
341
|
+
|
|
342
|
+
## Blue-noise dither (v1.2.0)
|
|
343
|
+
|
|
344
|
+
`rasterizeTo` gains one flag:
|
|
345
|
+
|
|
346
|
+
```js
|
|
347
|
+
mesh.rasterizeTo(buf, W, H, { dither: true, interpolation: 'smooth' });
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Delegates to `@zakkster/lite-color-engine >= 1.5.0`'s dithered packer
|
|
351
|
+
and shared 64×64 void-and-cluster tile. Per-pixel overhead is roughly
|
|
352
|
+
24% on typical raster sizes — one tile lookup plus a threshold-offset
|
|
353
|
+
gamma round replacing the standard `+ 0.5` rounding.
|
|
354
|
+
|
|
355
|
+
**Contract, exhaustively tested:**
|
|
356
|
+
|
|
357
|
+
- **`noise01 = 0.5` reproduces the plain packer exactly** — the identity
|
|
358
|
+
anchor. Consequence: dither adds an *at-most* ±1 per-channel deviation
|
|
359
|
+
from the undithered output.
|
|
360
|
+
- **Same noise value shared R/G/B per pixel** — luminance-patterned
|
|
361
|
+
dither, no chroma speckle. On a chroma-free mesh, dithered output is
|
|
362
|
+
chroma-free too (verified).
|
|
363
|
+
- **Alpha never dithered.** Byte-parity with the undithered α.
|
|
364
|
+
- **`dither: false` or absent → byte-identical to v1.2.0.** The branch
|
|
365
|
+
is resolved once per rasterize call, two loop bodies, no per-pixel
|
|
366
|
+
branch cost when off.
|
|
367
|
+
- **Composes with wrap.** `rasterizeTo` with both `wrapX` and `dither`
|
|
368
|
+
set produces per-channel ±1 deviation and preserves seamlessness. The
|
|
369
|
+
two features are orthogonal — wrap operates on sampling, dither on
|
|
370
|
+
packing.
|
|
371
|
+
|
|
372
|
+
**Where it shines:** shallow ramps and near-monochrome fills where the
|
|
373
|
+
undithered output shows visible 1-byte-wide bands. On a 4-stop L=0.30..0.34
|
|
374
|
+
gradient at 256px width, dither cuts the longest identical-pixel run by
|
|
375
|
+
more than 33% (from ~64 pixels undithered to ~40 dithered).
|
|
376
|
+
|
|
377
|
+
**Where it doesn't help:** high-contrast meshes where color transitions
|
|
378
|
+
already span multiple integer bytes per column. Dither is bounded ±1;
|
|
379
|
+
that's a lot of visual improvement on smooth surfaces, but invisible on
|
|
380
|
+
sharp gradients.
|
|
381
|
+
|
|
382
|
+
**Not on `rasterizeDeformedTo`.** The deformed rasterizer silently
|
|
383
|
+
ignores the flag (also throws `WRAP_DEFORMED_UNSUPPORTED` on wrapped
|
|
384
|
+
meshes, unchanged). Deformed + dither is scheduled with the v1.3
|
|
385
|
+
ghost-quad seam work.
|
|
386
|
+
|
|
287
387
|
## Benchmarks
|
|
288
388
|
|
|
289
389
|
Node v22, single thread, Linux x64. Run yourself: `npm run bench`.
|
package/llms.txt
CHANGED
|
@@ -214,3 +214,45 @@ mostly stuck on HSL or sRGB.
|
|
|
214
214
|
This library is that layer. Built once, factored to compose, tuned for
|
|
215
215
|
production rasterization. Pair it with @zakkster/lite-color (the OKLCH
|
|
216
216
|
foundation) and any UI framework to ship a gradient editor.
|
|
217
|
+
|
|
218
|
+
## v1.2.0 "Tiles" — toroidal mesh
|
|
219
|
+
|
|
220
|
+
`new MeshGradient(cols, rows, stops, { wrapX, wrapY })` — 4th opts arg with two independent flags (torus = both, cylinder = one). Both default `false`; when off, all code paths byte-identical to v1.1.0.
|
|
221
|
+
|
|
222
|
+
Structural wrap semantics:
|
|
223
|
+
- UV period: cell count is `cols` (not `cols - 1`) on wrapped axes; default control-point positions become `col / cols`.
|
|
224
|
+
- `sampleAt` on wrapped axes: `u = u - Math.floor(u)` replaces clamp; last-cell neighbour uses `(col + 1) % cols`. `sampleAt(0, v) === sampleAt(1, v)` exactly; any float is a valid coord (raw accumulating animation phase, negatives).
|
|
225
|
+
- Cubic mode + wrap = C¹ across the seam. `_sampleAtCubic` and `_cubicRow` use modulo neighbour indexing (`((col - 1) % cols + cols) % cols` and `(col + 2) % cols`) so Catmull-Rom reads real cells across the boundary. The flagship — no other mesh gradient lib does this.
|
|
226
|
+
- `rasterizeTo` on wrapped axes: `u = x / width` (drops the `- 1`). Pixel column 0 of the "next tile" lands at u ≡ 0, so `drawImage`-based tiling of the rasterized output butts perfectly.
|
|
227
|
+
- `rasterizeDeformedTo` throws `err.code = 'WRAP_DEFORMED_UNSUPPORTED'` on a wrapped mesh. Ghost-quad seam crossing is v1.3 work.
|
|
228
|
+
- `defaultMeshColor` accepts trailing `wrapX`, `wrapY` args. When set, hue advances a uniform `360/cols` per column (wrapX) or L switches to `cos(2π · rT)` and drift becomes sinusoidal (wrapY). Four-arg form byte-identical to v1.1.0.
|
|
229
|
+
|
|
230
|
+
Instance exposes `readonly wrapX: boolean`, `readonly wrapY: boolean` for downstream consumers.
|
|
231
|
+
|
|
232
|
+
Peer bumps: `@zakkster/lite-color-engine` `^1.5.0`, `@zakkster/lite-gradient` `^1.2.0`.
|
|
233
|
+
|
|
234
|
+
Cascade note: dither (D7/D8) slips clean to v1.2.0 with full gate. Both roadmap tracks (wrap D1-D6, dither D7-D8) ship together in 1.2.0.
|
|
235
|
+
|
|
236
|
+
## v1.2.0 "Tiles + Dither"
|
|
237
|
+
|
|
238
|
+
Blue-noise dithering on `MeshGradient.rasterizeTo` — D7/D8 from the Tiles roadmap, slipped intact from v1.2.0 per the cascade rule. No peer bumps (engine v1.5.0 already floor from v1.2.0).
|
|
239
|
+
|
|
240
|
+
Usage:
|
|
241
|
+
```
|
|
242
|
+
mesh.rasterizeTo(buf, W, H, { dither: true });
|
|
243
|
+
mesh.rasterizeTo(buf, W, H, { dither: true, interpolation: 'cubic' });
|
|
244
|
+
mesh.rasterizeTo(buf, W, H, { dither: true /* + wrap */ });
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Semantics:
|
|
248
|
+
- Delegates to `@zakkster/lite-color-engine >= 1.5.0`'s `packOklchBufferToUint32Dithered` + `getBlueNoise64`. Tile is a shared `Uint8Array(4096)` (64×64), decoded once from a base64 blob at first use.
|
|
249
|
+
- Per-pixel: `noise01 = (tile[((y & 63) << 6) | (x & 63)] + 0.5) / 256` — pure bit ops, torus wrap via mask. Same noise01 shared across R/G/B (no chroma speckle). Alpha undithered.
|
|
250
|
+
- Contract: `noise01 = 0.5` reproduces the plain packer exactly. Consequence: dither adds at most ±1 per-channel deviation vs undithered.
|
|
251
|
+
- Branch resolved ONCE per rasterizeTo call — two loop bodies. `dither: false` (or absent) walks the exact v1.2.0 code path, byte-identical output.
|
|
252
|
+
- Not on `rasterizeDeformedTo` (v1.3 ghost-quad work).
|
|
253
|
+
|
|
254
|
+
New export: `packOklchSingleDithered(l, c, h, alpha, noise01)` — scalar-arg wrapper reusing the same Float32Array(3) scratch as `packOklchSingle`.
|
|
255
|
+
|
|
256
|
+
Empirical dither overhead (5×5 smooth, 256²): 2.1M → 1.6M px/s, ~24%. Recorded in bench.
|
|
257
|
+
|
|
258
|
+
Total tests: 269/269 (218 v1.1.0 + 31 wrap + 3 wrap-alloc + 15 dither + 2 dither-alloc). Byte-parity gate on the v1.1.0 tests still holds unmodified.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-gradient-studio",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Authoring engine for OKLCH gradients
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "Authoring engine for OKLCH gradients \u2014 linear, radial, conic, and N\u00d7M mesh \u2014 with zero-GC rasterization, multi-format export (CSS/SCSS/Tailwind/JSON/SVG), and chroma-weighted palette extraction.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@zakkster/lite-color": "^1.0.6",
|
|
32
|
-
"@zakkster/lite-color-engine": "^1.
|
|
33
|
-
"@zakkster/lite-gradient": "^1.
|
|
32
|
+
"@zakkster/lite-color-engine": "^1.5.0",
|
|
33
|
+
"@zakkster/lite-gradient": "^1.2.0"
|
|
34
34
|
},
|
|
35
35
|
"keywords": [
|
|
36
36
|
"gradient",
|
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
|
+
}
|
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(
|
|
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,
|
|
@@ -313,6 +380,21 @@ export function sampleLut(lut: Uint32Array, t: number): number;
|
|
|
313
380
|
/** Pack a single OKLCH color directly to a 32-bit RGBA value. */
|
|
314
381
|
export function packOklchSingle(l: number, c: number, h: number, alpha?: number): number;
|
|
315
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
|
+
|
|
316
398
|
// ---------------------------------------------------------------------------
|
|
317
399
|
// Palette extraction
|
|
318
400
|
// ---------------------------------------------------------------------------
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
flattenStopsToBuffer,
|
|
21
21
|
sampleLut,
|
|
22
22
|
packOklchSingle,
|
|
23
|
+
packOklchSingleDithered,
|
|
23
24
|
} from './bake.js';
|
|
24
25
|
export { MeshGradient, defaultMeshColor, monochromeMesh } from './mesh.js';
|
|
25
26
|
export { formatCssLinear, formatCssRadial, formatCssConic } from './css-emitters.js';
|
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';
|