@zakkster/lite-patternforge
v1.0.0
Published
Zero-GC seamless-repeat pattern kernel. Grid, halfdrop, brick, mirror, and deterministic scatter layouts + motif seam-blend + audit. Composes with lite-hueforge, lite-color-engine/remap, lite-gradient-studio.
Maintainers
Readme
@zakkster/lite-patternforge
Zero-GC seamless-repeat pattern kernel. Grid, halfdrop, brick, mirror, and deterministic scatter layouts. Motif seam-blend pre-processor, seamless-score audit. Built for real-time preview scrubbing and long-running design tools.
The tile kernel the rest of the ecosystem was missing
lite-patternforge closes the last gap in the @zakkster color + surface pipeline. Hueforge does palettes and colorway variants. lite-color-engine/remap recolors rasters. lite-gradient-studio handles seamless mesh backgrounds. Nothing between them composed a motif into a repeat pattern — grid, halfdrop, brick, mirror, or scatter. Patternforge is that piece.
npm install @zakkster/lite-patternforgeimport { tileMotif, LAYOUTS } from "@zakkster/lite-patternforge";
// One motif → 4×4 halfdrop repeat, ready to blit
const motif = /* ImageData from user upload or procedural draw */;
const output = new ImageData(motif.width * 4, motif.height * 4);
tileMotif(motif, {
tilesX: 4,
tilesY: 4,
layout: LAYOUTS.HALFDROP,
offsetFrac: 0.5
}, output);
ctx.putImageData(output, 0, 0);Seven layouts, zero allocations on the hot path, RGBA-LE Uint32Array pixel format matching Canvas ImageData and @zakkster/lite-color-engine/remap. Direct-blit compatible with everything downstream.
Table of contents
- Why this exists
- What you get
- The seven layouts
- API reference
- Composability with the ecosystem
- Zero-GC design notes
- Design decisions worth knowing
- Testing
- What this is not
- Ecosystem
Why this exists
Surface pattern design has two problems that no library solves at once:
Real-time preview scrubbing. A textile designer moving a tile-count slider expects the preview to update at 60fps. If tiling a 128² motif into a 512² repeat allocates DOM objects or new pixel buffers per frame, GC pauses show up as jitter and the tool feels laggy on anything but a fresh laptop. Patternforge tiles at native memory speed with zero allocations after warm-up.
Deterministic output. When a variant explodes into 6 CVD-safe colorways × 3 layouts × 2 tile counts, that's 36 outputs — and the designer needs to be able to reproduce any specific one on demand. Every kernel here is deterministic. Scatter layouts use a seeded PRNG so
{ seed: 42 }today equals{ seed: 42 }tomorrow, on any machine.
Existing options: canvas drawImage in a loop (allocates layer state per call, no scatter/mirror), CSS background-repeat (grid only, no export path), or roll-your-own (weeks). Patternforge is the API for this specific job.
What you get
tileMotif(motif, opts, out)—ImageData-shaped convenience. Dispatches onopts.layout. The API most callers reach for.- Seven flat-array kernels for zero-alloc composition:
tileMotifU32— grid (straight repeat)tileMotifHalfdropU32— offset alternating columnstileMotifBrickU32— offset alternating row-bandstileMotifMirrorXU32— H-flip alternating columns (book-match)tileMotifMirrorYU32— V-flip alternating row-bandstileMotifMirrorXYU32— kaleidoscopic 2×2 book-matchtileMotifScatterU32— deterministic per-tile rotation + optional flips
makeMotifSeamlessU32— crossfade opposite edges of a motif so it grid-tiles without visible seams. Cosine-eased, symmetric.seamlessScore— audit function returning RMS RGB discontinuity across horizontal, vertical, and overall seams. Use it to decide whether a motif needs seam-blending before grid tiling, or whether mirror is a better choice.LAYOUTS— frozen enum of layout string constants for autocomplete and typo protection.
Full types ship in Patternforge.d.ts. Every export has JSDoc.
The seven layouts
Notation: tile positions are indexed (tx, ty) starting at (0, 0) in the top-left; motif coordinates within each tile are (mx, my).
Grid
The straight repeat. Every tile is an identical copy of the motif. The most common layout; the baseline everything else compares against.
Every seam is a hard cut. If your motif's right edge doesn't naturally match its left edge, grid layout will show it.
Halfdrop
Odd tile columns (tx = 1, 3, 5, ...) are offset vertically by motifH × offsetFrac pixels. With the default offsetFrac = 0.5, adjacent columns are shifted by exactly half the motif height. Column 0's motif row k appears at column 1's row (k + motifH/2) mod motifH.
Common in textile design (bricks-on-edge, scales, hexagonal-looking tessellations). Hides straight vertical seams that grid exposes.
Brick
Odd tile row-bands (ty = 1, 3, 5, ...) are offset horizontally by motifW × offsetFrac pixels, shifted right with wrap-around. Classic masonry pattern. Hides horizontal seams the same way halfdrop hides vertical ones.
Mirror-X (book-match, horizontal)
Odd tile columns are the motif flipped horizontally. Adjacent columns become mirror images, so the seam between them contains identical pixel columns twice — visually invisible. Excellent for photographic motifs.
Mirror-Y (book-match, vertical)
Same idea, other axis. Odd row-bands are the motif flipped vertically.
Mirror-XY (kaleidoscopic 2×2 book-match)
Both axes. Each 2×2 tile block reads as the motif plus its H-flip, V-flip, and HV-flip variants. Every seam invisible. Most forgiving for photographic and highly-directional motifs.
Scatter (deterministic random)
Grid-aligned tile placement, but each tile picks one transform variant deterministically from opts.rotations × optional flipH × optional flipV, using a seeded PRNG. Same seed + same grid → same output, on any machine, forever.
Rotations at 90° / 270° swap tile dimensions and therefore require a square motif. For non-square motifs, restrict rotations to [0, 180].
Composed with flipH: true and flipV: true, scatter with all four rotations produces 16 distinct variants, distributed across tile positions by the hash.
API reference
Flat-array kernels
All seven layout kernels share the same call shape:
kernel(motifU32, motifW, motifH, outU32, tilesX, tilesY, [layoutOpts])motifU32—Uint32Arrayof length ≥motifW × motifH, RGBA-LE encoded (R = value & 0xFF,G = (value >> 8) & 0xFF,B = (value >> 16) & 0xFF,A = (value >> 24) & 0xFF). AnyUint8ClampedArrayfrom a CanvasImageDatacan be viewed asUint32Arrayat zero cost.outU32—Uint32Arrayof length ≥motifW × motifH × tilesX × tilesY. Written in place.tilesX,tilesY— positive integers.- Layout-specific options come last:
offsetFracfor halfdrop/brick, aScatterOptionsobject for scatter.
Every kernel validates its inputs (throws TypeError on wrong types, RangeError on non-positive or undersized dimensions). Validation happens once up front; the hot loop assumes correctness.
tileMotifU32(motifU32, motifW, motifH, outU32, tilesX, tilesY)
Grid layout. Row-by-row cache-friendly copy loop.
tileMotifHalfdropU32(motifU32, motifW, motifH, outU32, tilesX, tilesY, offsetFrac?)
Halfdrop layout. offsetFrac defaults to 0.5. Any finite number accepted (wrapped mod 1); NaN/Infinity throw RangeError. offsetFrac = 0 naturally falls back to grid behavior — no special case.
tileMotifBrickU32(motifU32, motifW, motifH, outU32, tilesX, tilesY, offsetFrac?)
Brick layout. Same offsetFrac conventions as halfdrop. Shifts right with wrap-around.
tileMotifMirrorXU32(...) / tileMotifMirrorYU32(...) / tileMotifMirrorXYU32(...)
Mirror layouts. Same shared signature, no extra options.
tileMotifScatterU32(motifU32, motifW, motifH, outU32, tilesX, tilesY, opts?)
interface ScatterOptions {
rotations?: (0 | 90 | 180 | 270)[]; // default [0, 180]
flipH?: boolean; // default false
flipV?: boolean; // default false
seed?: number; // default 0
}Rotations of 90° or 270° swap tile dimensions and require a square motif — throws RangeError otherwise. Rotations [0, 180] work on any dimensions.
ImageData wrapper
tileMotif(motif, opts, out)
ImageData-shaped convenience. Dispatches on opts.layout (default 'grid'). Options object accepts everything the underlying kernel accepts, plus tilesX and tilesY.
interface TileMotifOptions extends ScatterOptions {
tilesX?: number; // default 1
tilesY?: number; // default 1
layout?: PatternLayout; // default 'grid'
offsetFrac?: number; // for halfdrop / brick
}Output-dimension contract is strict: out.width must equal motif.width × tilesX and out.height must equal motif.height × tilesY. Throws RangeError on mismatch. This is deliberate — silent truncation would hide grid-math bugs.
Motif pre-processor
makeMotifSeamlessU32(inU32, motifW, motifH, outU32, bandFrac?)
Cross-blend a motif's opposing edges so the result grid-tiles seamlessly. Fades the right band toward the left edge, bottom band toward the top edge, using a cosine ease. At the exact seam positions, output is a 50/50 blend of both edges — grid tiling shows no boundary.
bandFrac∈(0, 0.5]. Default0.1(10% of each dimension). Larger bands are softer but blur more of the motif. Smaller are sharper but less forgiving.inU32is not modified;outU32receives the seamless variant.- Zero allocation on the hot path.
The mirror kernels (Mirror-X, Mirror-Y, Mirror-XY) achieve seamlessness a different way (reflection) and don't need this pre-processor.
Corner artifacts: horizontal and vertical blending are applied sequentially, so the four corners get double-blended. This introduces a very slight asymmetry that's acceptable for photographic motifs and typically imperceptible below bandFrac = 0.15. Documented and honest — for perfect corners, use mirror-xy instead.
Audit
seamlessScore(motifU32, motifW, motifH) → { horizontal, vertical, overall }
Returns three scores in [0, 1] where lower is better (0 = perfectly seamless, 1 = maximum discontinuity):
horizontal— RMS RGB discontinuity across the horizontal seam (right edge vs left edge, position-matched by row).vertical— RMS RGB discontinuity across the vertical seam.overall— Euclidean combination√(h² + v²) / √2.
Alpha channel ignored. Empirically calibrated thresholds:
| Score | Interpretation | | ----------- | ------------------------------------------ | | < 0.02 | imperceptible | | 0.02 – 0.05 | visible on close inspection | | 0.05 – 0.15 | obvious seam | | > 0.15 | disruptive; needs seam blend or mirror |
Use it to gate whether your workflow should reach for makeMotifSeamlessU32 or switch to a mirror layout.
Composability with the ecosystem
The whole pipeline, end to end:
import { extractPaletteWithWeights, createColorways } from '@zakkster/lite-hueforge';
import { remapPixelsToPalette } from '@zakkster/lite-color-engine/remap';
import { tileMotif, seamlessScore, LAYOUTS } from '@zakkster/lite-patternforge';
// 1. Analyze the motif
const motif = /* ImageData from user */;
const score = seamlessScore(new Uint32Array(motif.data.buffer), motif.width, motif.height);
// score.overall > 0.05 → use a mirror layout or seam-blend first
// 2. Extract a palette + generate CVD-safe variants
const master = extractPaletteWithWeights(motif, 5).map(s => s.color);
const variants = createColorways(master, { count: 6, seed: 42 });
// 3. Tile the motif into a 4×4 repeat, book-matched to hide seams
const tiled = new ImageData(motif.width * 4, motif.height * 4);
tileMotif(motif, { tilesX: 4, tilesY: 4, layout: LAYOUTS.MIRROR_XY }, tiled);
// 4. Recolor for each variant — a 6-way pattern collection
const outputs = [];
for (const palette of variants) {
const recolored = new ImageData(tiled.width, tiled.height);
remapPixelsToPalette(
tiled.data,
paletteToOklchFloat32(palette),
new Uint32Array(recolored.data.buffer),
tiled.width * tiled.height,
palette.length,
{ preserveLightness: true }
);
outputs.push(recolored);
}
// `outputs` is the designer's 6-variant pattern collection.Every step is deterministic. Every step is zero-GC on the hot path. Every step composes with the next through flat-array primitives — no byte swapping, no format translation, no allocation between stages.
Zero-GC design notes
Every kernel in this library allocates zero heap objects on its main loop. Validation happens once up front; the tile-copy loops afterward do nothing but integer arithmetic on typed-array indices and pixel-value transfers.
| Operation | Allocations |
| ---------------------------------- | ----------- |
| tileMotifU32 main loop | 0 |
| tileMotifHalfdropU32 main loop | 0 |
| tileMotifBrickU32 main loop | 0 |
| tileMotifMirrorXU32 main loop | 0 |
| tileMotifMirrorYU32 main loop | 0 |
| tileMotifMirrorXYU32 main loop | 0 |
| tileMotifScatterU32 main loop | 0 (per-tile hash is inline) |
| makeMotifSeamlessU32 main loop | 0 (initial Uint32Array.set is native memcpy, not a JS allocation) |
| seamlessScore | 1 (the returned result object) |
| tileMotif wrapper | 2 (Uint32Array views over the ImageData buffers — zero-copy) |
The wrapper's two view allocations happen once per call, not per pixel — a real-time preview scrubbing use case (say, dragging a slider that changes tilesX) allocates ~2 small objects per input event. Well within V8's young-gen nursery budget; sub-millisecond collections that never surface as visible jank.
For the pure-hot-path use case (tiling in a requestAnimationFrame loop at 60fps), skip the wrapper and call the *U32 kernels directly. Then the entire pipeline allocates literally nothing per frame.
Verified by the realistic-scale test: 128² motif tiled 4×4 → 1 Mpx output, 20 iterations across every kernel, pre-allocated buffers, no observable heap growth.
Design decisions worth knowing
- RGBA-LE Uint32 pixel convention.
Uint8ClampedArrayfrom CanvasImageDatais viewable asUint32Arrayat zero cost on little-endian machines (Chrome, Firefox, Safari, Node — all little-endian). The remap kernel in@zakkster/lite-color-engine/remapoutputs the same format. Direct pipeline composition, no byte swapping. - Strict output-size contract.
tileMotifthrows on dimension mismatch rather than silently truncating. Silent truncation is the enemy of deterministic graphics tools. Strict-mode forces the consumer to calculate the grid math correctly upfront, preventing invisible layout bugs downstream. - Halfdrop is between-tile, not within-tile. With
tilesX = 1there's only one tile column and nothing to alternate with — halfdrop degenerates to grid. This is the correct semantic (halfdrop is a tile arrangement, not a pixel transform) but occasionally surprises callers. - Brick shifts right.
offX = round(motifW × offsetFrac); odd row-bands shift right byoffXpixels with wrap-around. Direction is documented and consistent — the alternative (shift-left) produces the same visual pattern (brick is symmetric) but different pixel-exact output. Test-covered explicitly with themotifW = 6, offsetFrac = 1/3case where left and right differ. - Scatter's rotation set is validated. Only
[0, 90, 180, 270]are valid rotation values. Arbitrary rotation angles would require pixel resampling and are out of scope for a zero-GC kernel. 90° and 270° require a square motif; the library refuses to guess what to do with the dimension swap otherwise. offsetFracwraps mod 1.1.5behaves the same as0.5,-0.25same as0.75. Permissive input; the only thing rejected is non-finite (NaN, Infinity).
Testing
42 deterministic tests, all pass. Ships tests for:
- Every kernel's input validation surface (TypeError / RangeError paths)
- Pixel-exact correctness on small motifs (
2×2,3×3,4×4) with expected outputs computed by hand - Asymmetric grid shapes (
3×1tiles of2×3motif) to catch axis-swap bugs - Halfdrop's between-tile offset semantics with a full 4×4-motif 2×2-grid pixel check
- Brick's shift direction with the
motifW = 6, offsetFrac = 1/3case that breaks left/right symmetry - Mirror layouts' invisible-seam property (adjacent pixels across a mirrored seam must match)
- Mirror-XY's kaleidoscopic contract (2×2 block corners must all be the same source pixel)
- Scatter determinism (same seed = same output) and non-determinism (different seeds diverge)
- Scatter's dimension-swap rejection on non-square motifs with 90°/270° rotations
makeMotifSeamlessU32reducingseamlessScore.horizontalon a hard red/blue seamseamlessScorereturning0on uniform motifs and wrap-safe cyclic gradients- Realistic-scale zero-allocation heuristic: 128² motif × 4×4 grid × 20 iters across every kernel, pre-allocated buffers
npm test # ~200ms
npm run test:gc # zero-gc suite with --expose-gc
npm run verify # both, gate for publishWhat this is not
- Not a graphics library. Patternforge tiles motifs; it doesn't draw them. Bring your own procedural motif, upload, or SVG rasterization.
- Not a resampler. Rotations are 90° increments only. Arbitrary rotation angles need pixel resampling (bilinear, bicubic) which is out of scope for a zero-GC kernel — do it upstream if you need it.
- Not a color engine. Patternforge doesn't know about OKLCH, sRGB, gamut, or palettes. It moves pixels. Pair with
@zakkster/lite-hueforgeand@zakkster/lite-color-enginefor the color half of surface design. - Not a WebGL renderer. Everything runs on the CPU against
Uint32Arraybuffers. If you need >30 Mpx/second sustained, you want the GPU. For the tile counts real designers use (typically ≤ 4Mpx output), CPU is more than enough and simpler to compose. - Not a GUI. No React components, no drag-and-drop, no export dialogs. Bring your own UI; wire it to this kernel.
Ecosystem
Part of the @zakkster zero-GC stack:
lite-signal— zero-GC reactive graph for hot pathslite-color-engine— OKLCH color pipeline with/gamutand/remapsub-exportslite-hueforge— palette extraction, colorway generation, CVD auditlite-gradient-studio— mesh gradients with toroidal wraplite-color-lerp— OKLCH LUT baking, cyclic sampling, MINDE variantslite-gradient— N-stop OKLCH gradients with cyclic supportlite-patternforge— this package
License
MIT (c) Zahary Shinikchiev <[email protected]>
