/* =========================================================================
* DSSL: texture.js
*
* .card { texture: paper }
* .metal { texture: brushed intensity 0.6 color2 #cfd8e3 }
* .mine { texture: noise scale 3 octaves 4 color #2a1a0a color2 #c08040 blend multiply }
*
* A texture is a LAYER of generated image that sits on top of an element's own
* background. There are generators (noise, bump, lines, dots, grid, checker,
* weave, carbon, waves, hex) and settings (scale, intensity, contrast, color,
* color2, blend, ...). Every named preset below is nothing more than a stored
* combination of those, so anything a preset does you can write yourself, and
* you can name your own with a `behavior` block.
*
* This file is pure: no DOM. parseTexture() runs in the compiler and checks the
* words; buildTexture() turns a parsed layer into CSS background pieces and is
* given a function that resolves colour words (the engine passes one that
* understands the page's tokens; tests pass parseColor).
* ========================================================================= */
import { parseColor, withAlpha, toRgba, mix } from './color.js';
const mix2 = (x, y, t) => mix(x, y, t, 'srgb');
/* ------------------------------------------------------------------ *
* vocabulary
* ------------------------------------------------------------------ */
export const BLEND_MODES = ['normal', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'color-burn', 'color-dodge', 'darken', 'lighten', 'difference', 'exclusion'];
export const GENERATORS = ['noise', 'bump', 'lines', 'dots', 'grid', 'checker', 'weave', 'carbon', 'waves', 'hex', 'rings',
'bricks', 'herringbone', 'plaid', 'scales', 'chevron', 'diamonds', 'triangles', 'starfield', 'terrazzo']; // 0.16.0: the last nine
/** the settings a texture layer understands, with how many value words each takes */
export const TEXTURE_KEYS = {
scale: { words: 1, kind: 'number', min: 0.1, max: 2000, doc: 'feature size in px: how big the grains, cells or lines are' },
intensity: { words: 1, kind: 'number', min: 0, max: 2, doc: 'how strong the layer is, 0 to 1 (more than 1 over-drives it)' },
contrast: { words: 1, kind: 'number', min: 0, max: 40, doc: 'noise contrast: 1 is as generated, higher is harsher' },
threshold: { words: 1, kind: 'number', min: 0, max: 1, doc: 'noise cut-off: only values above it show (glitter uses a high one)' },
contour: { words: 1, kind: 'number', min: 0.005, max: 0.5, doc: 'draw thin lines where the noise crosses the threshold, instead of a plain cut: veins, rings, contours' },
octaves: { words: 1, kind: 'number', min: 1, max: 8, int: true, doc: 'noise detail layers, 1 is smooth, 5 is rough' },
seed: { words: 1, kind: 'number', min: 0, max: 99999, int: true, doc: 'a different random pattern with the same look' },
stretch: { words: 2, kind: 'numbers', min: 0.1, max: 200, doc: 'stretch noise features along x and y, e.g. stretch 40 1 for wood grain' },
depth: { words: 1, kind: 'number', min: 0, max: 30, doc: 'bump height: how deep the relief looks' },
light: { words: 1, kind: 'number', min: -360, max: 360, doc: 'bump light direction in degrees' },
angle: { words: 1, kind: 'number', min: -360, max: 360, doc: 'direction of lines and patterns, in degrees' },
thickness: { words: 1, kind: 'number', min: 0.1, max: 500, doc: 'line or grid-line width in px' },
radius: { words: 1, kind: 'number', min: 0.02, max: 1, doc: 'dot size as a fraction of the cell (plaid: how wide the bands are as a fraction of the repeat)' },
softness: { words: 1, kind: 'number', min: 0, max: 1, doc: 'how soft the edges of dots and lines are' },
animate: { words: 1, kind: 'number', min: 0, max: 60000, doc: 'milliseconds per shimmer step (film-grain flicker); 0 is still' },
type: { words: 1, kind: 'word', values: ['fractal', 'turbulence'], doc: 'noise flavour: fractal is clouds, turbulence is veins' },
blend: { words: 1, kind: 'word', values: BLEND_MODES, doc: 'how the layer mixes with what is under it' },
color: { words: 1, kind: 'color', doc: 'the main colour (dark end of noise, ink of lines and dots)' },
color2: { words: 1, kind: 'color', doc: 'the second colour (light end of noise, background cell of patterns)' },
figure: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: how strongly the growth rings arch into a cathedral figure, 0 is straight grain' },
warp: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: how much each ring wobbles along its length' },
pores: { words: 1, kind: 'number', min: 0, max: 1, doc: 'rings generator: fine fibre and pore streaks between the rings' },
knots: { words: 1, kind: 'number', min: 0, max: 8, int: true, doc: 'rings generator: how many knots in one tile (the pattern repeats, so keep it small)' },
planks: { words: 1, kind: 'number', min: 24, max: 1200, doc: 'rings generator: lay it out as planks this many px tall, with a seam and a slightly different board each' },
symmetrical: { words: 1, kind: 'word', values: ['both', 'x', 'y'], optional: true, doc: 'mirror every other tile, so each edge meets its own reflection: no seam, and the repeat is twice as far apart. x mirrors left to right, y top to bottom, both (the default) does both. Works on the image-based textures (noise, bump, rings, waves, hex).' },
density: { words: 1, kind: 'number', min: 0.05, max: 20, doc: 'starfield and terrazzo: how many stars or chips in a tile, 1 is normal' },
invert: { words: 0, kind: 'flag', doc: 'swap the two colours' },
};
/* ------------------------------------------------------------------ *
* presets: a name is a stored set of settings, nothing more
* layers: one or more { gen, ...settings }. First entries sit on top.
* ------------------------------------------------------------------ */
const K = (r, g, b, a = 1) => `rgba(${r},${g},${b},${a})`;
export const PRESETS = {
// ---- grains and noise
grain: { doc: 'fine film grain, neutral on any colour', mask: true,
layers: [{ gen: 'noise', scale: 1.1, octaves: 2, contrast: 1.7, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
noise: { doc: 'coarser static', mask: true,
layers: [{ gen: 'noise', scale: 2.6, octaves: 3, contrast: 1.9, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
rough: { doc: 'gritty relief, like a rough cast wall', mask: true,
layers: [{ gen: 'bump', scale: 3, octaves: 4, depth: 3.4, light: 135, intensity: 0.75, color: '#000', color2: '#fff', blend: 'overlay' }] },
sand: { doc: 'warm fine sand grains', mask: true,
layers: [{ gen: 'bump', scale: 1.5, octaves: 3, depth: 2.2, light: 130, intensity: 0.7, color: '#5a4426', color2: '#fff1d0', blend: 'overlay' }] },
paper: { doc: 'soft paper fibre with a little unevenness',
layers: [
{ gen: 'noise', scale: 1.5, octaves: 4, contrast: 1.2, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay' },
{ gen: 'noise', scale: 120, octaves: 3, contrast: 1.3, intensity: 0.25, color: '#000', color2: '#fff', blend: 'soft-light', seed: 7 },
] },
cloud: { doc: 'big soft cloudy variation', mask: true,
layers: [{ gen: 'noise', scale: 90, octaves: 5, contrast: 1.6, intensity: 0.7, color: '#000', color2: '#fff', blend: 'soft-light' }] },
concrete: { doc: 'pitted concrete',
layers: [
{ gen: 'bump', scale: 6, octaves: 5, depth: 2.6, light: 135, intensity: 0.7, color: '#000', color2: '#fff', blend: 'overlay' },
{ gen: 'noise', scale: 140, octaves: 4, contrast: 1.5, intensity: 0.45, color: '#000', color2: '#fff', blend: 'soft-light', seed: 3 },
] },
stone: { doc: 'carved stone with broad relief',
layers: [{ gen: 'bump', scale: 26, octaves: 5, depth: 2.4, light: 125, intensity: 0.75, color: '#000', color2: '#fff', blend: 'overlay' }] },
crumpled: { doc: 'crumpled foil or paper, big creases',
layers: [{ gen: 'bump', scale: 34, octaves: 4, depth: 4, light: 135, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay', seed: 11 }] },
glitter: { doc: 'sparse bright sparkles', mask: true,
layers: [{ gen: 'noise', scale: 1.2, octaves: 1, contrast: 16, threshold: 0.66, intensity: 1, color: 'rgba(255,255,255,0)', color2: '#fff', blend: 'screen', seed: 5 }] },
frost: { doc: 'fine icy crystals',
layers: [{ gen: 'bump', scale: 2.2, octaves: 4, depth: 3.2, light: 140, intensity: 0.95, color: 'rgba(160,210,255,0)', color2: '#ffffff', blend: 'screen' }] },
// ---- natural, stretched
marble: { doc: 'veined marble',
layers: [
{ gen: 'noise', scale: 120, octaves: 5, contour: 0.035, threshold: 0.5, intensity: 0.9, color: '#6c6877', color2: 'rgba(247,245,240,0)', blend: 'multiply', seed: 4 },
{ gen: 'noise', scale: 120, octaves: 5, contour: 0.16, threshold: 0.5, intensity: 0.25, color: '#9b97a6', color2: 'rgba(247,245,240,0)', blend: 'multiply', seed: 4 },
{ gen: 'noise', scale: 200, octaves: 3, contrast: 1.4, intensity: 0.3, color: '#cfc9c0', color2: '#fbfaf7', blend: 'soft-light', seed: 21 },
] },
wood: { doc: 'long wood grain, along x (use angle 90 for vertical)',
layers: [{ gen: 'noise', scale: 4, octaves: 3, contrast: 1.8, stretch: [40, 1], intensity: 1, color: '#7a4b24', color2: '#c99a62', blend: 'multiply' }] },
oak: { doc: 'oak: open grain, tan with brown rings',
layers: [{ gen: 'rings', scale: 11, color: '#8a5f33', color2: '#c9a574', contrast: 1.3, figure: 0.55, warp: 0.45, pores: 0.5, seed: 3, blend: 'normal' }] },
walnut: { doc: 'walnut: dark chocolate with black-brown rings',
layers: [{ gen: 'rings', scale: 9, color: '#2b1a10', color2: '#6a4631', contrast: 1.2, figure: 0.5, warp: 0.55, pores: 0.45, seed: 8, blend: 'normal' }] },
cherry: { doc: 'cherry: warm red-brown with fine rings',
layers: [{ gen: 'rings', scale: 8, color: '#7a3a1c', color2: '#bb7443', contrast: 1.1, figure: 0.4, warp: 0.35, pores: 0.35, seed: 5, blend: 'normal' }] },
maple: { doc: 'maple: pale, tight, nearly plain grain',
layers: [{ gen: 'rings', scale: 13, color: '#c79a5a', color2: '#ecd6ab', contrast: 0.8, figure: 0.25, warp: 0.3, pores: 0.25, seed: 11, blend: 'normal' }] },
pine: { doc: 'pine: light yellow wood with wide rings and knots',
layers: [{ gen: 'rings', scale: 16, color: '#b5742f', color2: '#e6c081', contrast: 1.4, figure: 0.65, warp: 0.5, pores: 0.4, knots: 2, seed: 14, blend: 'normal' }] },
mahogany: { doc: 'mahogany: deep red-brown, straight grain',
layers: [{ gen: 'rings', scale: 10, color: '#4a2012', color2: '#8c4727', contrast: 1.1, figure: 0.3, warp: 0.3, pores: 0.4, seed: 6, blend: 'normal' }] },
ebony: { doc: 'ebony: nearly black with faint streaks',
layers: [{ gen: 'rings', scale: 12, color: '#0c0908', color2: '#2d2621', contrast: 0.7, figure: 0.2, warp: 0.25, pores: 0.3, seed: 2, blend: 'normal' }] },
birch: { doc: 'birch: creamy pale wood with fine rings',
layers: [{ gen: 'rings', scale: 10, color: '#d2ae74', color2: '#f0dfb9', contrast: 0.9, figure: 0.3, warp: 0.35, pores: 0.25, seed: 9, blend: 'normal' }] },
'oak-planks': { doc: 'oak laid as planks, with seams',
layers: [{ gen: 'rings', scale: 11, color: '#8a5f33', color2: '#c9a574', contrast: 1.3, figure: 0.55, warp: 0.45, pores: 0.5, planks: 110, seed: 3, blend: 'normal' }] },
'walnut-planks': { doc: 'walnut laid as planks, with seams',
layers: [{ gen: 'rings', scale: 9, color: '#2b1a10', color2: '#6a4631', contrast: 1.2, figure: 0.5, warp: 0.55, pores: 0.45, planks: 96, seed: 8, blend: 'normal' }] },
bark: { doc: 'rough vertical bark',
layers: [{ gen: 'bump', scale: 10, octaves: 4, depth: 5, stretch: [1, 6], light: 150, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay' }] },
leather: { doc: 'pebbled leather',
layers: [{ gen: 'bump', scale: 5, octaves: 2, depth: 4.2, light: 120, intensity: 0.85, color: '#000', color2: '#fff', blend: 'overlay' }] },
brushed: { doc: 'brushed metal, fine lines along x',
layers: [{ gen: 'noise', scale: 1.2, octaves: 2, contrast: 1.5, stretch: [60, 1], intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay' }] },
// ---- woven
canvas: { doc: 'artist canvas weave',
layers: [
{ gen: 'weave', scale: 3, thickness: 1, intensity: 0.5, color: '#000', blend: 'multiply' },
{ gen: 'noise', scale: 1.4, octaves: 3, contrast: 1.3, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay' },
] },
linen: { doc: 'loose linen weave with slubs',
layers: [
{ gen: 'weave', scale: 4, thickness: 1.2, intensity: 0.4, color: '#5a4a32', blend: 'multiply' },
{ gen: 'noise', scale: 80, octaves: 3, contrast: 1.4, stretch: [8, 1], intensity: 0.25, color: '#000', color2: '#fff', blend: 'soft-light', seed: 9 },
] },
denim: { doc: 'diagonal twill, blue by default',
layers: [
{ gen: 'lines', angle: 45, scale: 3, thickness: 1.4, intensity: 0.45, color: '#0d2147', blend: 'multiply' },
{ gen: 'noise', scale: 1.3, octaves: 3, contrast: 1.4, intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay', seed: 2 },
] },
carbon: { doc: 'carbon fibre twill',
layers: [{ gen: 'carbon', scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' }] },
// ---- patterns
scanlines: { doc: 'CRT scanlines',
layers: [{ gen: 'lines', angle: 0, scale: 4, thickness: 2, intensity: 0.28, color: '#000', blend: 'normal' }] },
halftone: { doc: 'newsprint dots',
layers: [{ gen: 'dots', scale: 8, radius: 0.2, softness: 0.2, intensity: 0.55, color: '#000', blend: 'multiply', stagger: true }] },
dots: { doc: 'a plain dot grid',
layers: [{ gen: 'dots', scale: 14, radius: 0.16, softness: 0.05, intensity: 0.35, color: '#000', blend: 'normal' }] },
lines: { doc: 'diagonal hairlines',
layers: [{ gen: 'lines', angle: 45, scale: 8, thickness: 1, intensity: 0.3, color: '#000', blend: 'normal' }] },
grid: { doc: 'a square grid, like graph paper',
layers: [{ gen: 'grid', scale: 24, thickness: 1, intensity: 0.25, color: '#000', blend: 'normal' }] },
checker: { doc: 'checkerboard',
layers: [{ gen: 'checker', scale: 24, intensity: 0.12, color: '#000', blend: 'normal' }] },
crosshatch: { doc: 'crossed hairlines',
layers: [
{ gen: 'lines', angle: 45, scale: 6, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
{ gen: 'lines', angle: -45, scale: 6, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
] },
stripes: { doc: 'bold diagonal stripes',
layers: [{ gen: 'lines', angle: 45, scale: 32, thickness: 16, intensity: 0.12, color: '#000', blend: 'normal' }] },
waves: { doc: 'soft repeating waves',
layers: [{ gen: 'waves', scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' }] },
hex: { doc: 'honeycomb',
layers: [{ gen: 'hex', scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' }] },
// ---- 0.16.0: walls, floors and cloth
bricks: { doc: 'a red brick wall, light mortar',
layers: [
{ gen: 'bump', scale: 3, octaves: 3, depth: 2.2, light: 135, intensity: 0.55, color: '#000', color2: '#fff', blend: 'overlay', seed: 3 },
{ gen: 'bricks', scale: 56, thickness: 3.5, intensity: 1, color: '#d9d0c4', color2: '#a4402b', blend: 'normal' },
] },
'bricks-old': { doc: 'weathered dark brick',
layers: [
{ gen: 'noise', scale: 40, octaves: 4, contrast: 1.5, intensity: 0.5, color: '#000', color2: '#fff', blend: 'soft-light', seed: 12 },
{ gen: 'bump', scale: 3, octaves: 4, depth: 3.2, light: 135, intensity: 0.7, color: '#000', color2: '#fff', blend: 'overlay', seed: 5 },
{ gen: 'bricks', scale: 52, thickness: 4, intensity: 1, color: '#6d665e', color2: '#74301f', blend: 'normal' },
] },
herringbone: { doc: 'parquet laid in a zigzag, every board a slightly different shade',
layers: [
{ gen: 'noise', scale: 1.2, octaves: 3, contrast: 1.4, stretch: [30, 1], intensity: 0.25, color: '#000', color2: '#fff', blend: 'overlay', seed: 4 },
{ gen: 'herringbone', scale: 22, thickness: 1.4, intensity: 1, color: '#2a1a0e', color2: '#b9854d', seed: 4, blend: 'normal' },
] },
plaid: { doc: 'simple plaid: wide bands crossing, thin lines between',
layers: [{ gen: 'plaid', scale: 64, thickness: 2, intensity: 0.5, color: '#9b1b1b', blend: 'multiply' }] },
tartan: { doc: 'a red tartan with green and navy bands',
layers: [
{ gen: 'plaid', scale: 96, thickness: 3, radius: 0.18, intensity: 0.6, color: '#0f1f55', blend: 'multiply' },
{ gen: 'plaid', scale: 48, thickness: 2, radius: 0.3, intensity: 0.65, color: '#12503a', blend: 'multiply' },
{ gen: 'plaid', scale: 48, thickness: 1, radius: 0.001, intensity: 1, color: '#a11d24', color2: '#a11d24', blend: 'normal' },
] },
gingham: { doc: 'small checks, the picnic cloth',
layers: [{ gen: 'plaid', scale: 28, thickness: 1, radius: 0.5, intensity: 0.5, color: '#c0392b', color2: '#ffffff', blend: 'normal' }] },
scales: { doc: 'overlapping scales',
layers: [{ gen: 'scales', scale: 36, thickness: 1.2, intensity: 0.4, color: '#000', blend: 'normal' }] },
mermaid: { doc: 'teal fish scales',
layers: [{ gen: 'scales', scale: 34, thickness: 1.4, intensity: 1, color: '#0a5560', color2: '#1fa8a0', blend: 'normal' }] },
shingles: { doc: 'roof shingles',
layers: [{ gen: 'scales', scale: 60, thickness: 2, intensity: 1, color: '#201c1a', color2: '#5b4a42', blend: 'normal' }] },
chevron: { doc: 'zigzag bands',
layers: [{ gen: 'chevron', scale: 40, thickness: 6, intensity: 0.3, color: '#000', blend: 'normal' }] },
argyle: { doc: 'a diamond lattice, filled',
layers: [{ gen: 'diamonds', scale: 44, thickness: 1.4, intensity: 0.9, color: '#e8e0d0', color2: '#2f3e6b', blend: 'normal' }] },
lowpoly: { doc: 'low-poly facets in two tones',
layers: [{ gen: 'triangles', scale: 72, intensity: 0.5, color: '#000', color2: '#fff', seed: 3, blend: 'overlay' }] },
tiles: { doc: 'square tiles with grout',
layers: [
{ gen: 'bump', scale: 40, octaves: 2, depth: 1.4, light: 135, intensity: 0.4, color: '#000', color2: '#fff', blend: 'overlay', seed: 6 },
{ gen: 'grid', scale: 64, thickness: 3, intensity: 0.5, color: '#000', blend: 'normal' },
] },
blueprint: { doc: 'drafting grid, fine and coarse',
layers: [
{ gen: 'grid', scale: 100, thickness: 1.4, intensity: 0.5, color: '#ffffff', blend: 'normal' },
{ gen: 'grid', scale: 20, thickness: 1, intensity: 0.22, color: '#ffffff', blend: 'normal' },
] },
// ---- 0.16.0: stone, ground and metal
granite: { doc: 'speckled granite',
layers: [
{ gen: 'noise', scale: 1.8, octaves: 1, contrast: 9, threshold: 0.62, intensity: 0.5, color: 'rgba(0,0,0,0)', color2: '#000', blend: 'multiply', seed: 2 },
{ gen: 'noise', scale: 1.8, octaves: 1, contrast: 9, threshold: 0.7, intensity: 0.55, color: 'rgba(255,255,255,0)', color2: '#fff', blend: 'screen', seed: 7 },
{ gen: 'bump', scale: 8, octaves: 3, depth: 1.2, light: 135, intensity: 0.4, color: '#000', color2: '#fff', blend: 'overlay', seed: 4 },
] },
slate: { doc: 'layered slate',
layers: [
{ gen: 'bump', scale: 18, octaves: 4, depth: 2.4, stretch: [2.4, 1], light: 135, intensity: 0.8, color: '#000', color2: '#fff', blend: 'overlay', seed: 5 },
{ gen: 'noise', scale: 90, octaves: 3, contrast: 1.4, intensity: 0.35, color: '#000', color2: '#fff', blend: 'soft-light', seed: 8 },
] },
sandstone: { doc: 'banded sandstone',
layers: [
{ gen: 'bump', scale: 3, octaves: 3, depth: 1.6, light: 130, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay', seed: 9 },
{ gen: 'noise', scale: 10, octaves: 3, contrast: 1.6, stretch: [30, 1], intensity: 0.8, color: '#8a5a3a', color2: '#e2b784', blend: 'multiply', seed: 14 },
] },
terrazzo: { doc: 'chips of stone in cement',
layers: [{ gen: 'terrazzo', scale: 240, density: 1, intensity: 0.9, color: '#c9a98f', color2: '#6b7a8f', seed: 6, blend: 'normal' }] },
confetti: { doc: 'bright scraps scattered, flat',
layers: [{ gen: 'terrazzo', scale: 260, density: 0.5, intensity: 1, color: '#ff4d6d', color2: '#4dabf7', seed: 11, blend: 'normal' }] },
rust: { doc: 'rusted metal',
layers: [
{ gen: 'bump', scale: 4, octaves: 4, depth: 3, light: 135, intensity: 0.6, color: '#000', color2: '#fff', blend: 'overlay', seed: 3 },
{ gen: 'noise', scale: 70, octaves: 5, contrast: 1.9, intensity: 0.85, color: '#3b1d0b', color2: '#b4602a', blend: 'multiply', seed: 5 },
] },
corrugated: { doc: 'corrugated metal sheet',
layers: [{ gen: 'lines', angle: 90, scale: 12, thickness: 6, softness: 1, intensity: 0.3, color: '#000', blend: 'overlay' }] },
cork: { doc: 'cork board',
layers: [
{ gen: 'bump', scale: 4, octaves: 3, depth: 3, light: 135, intensity: 0.7, color: '#000', color2: '#fff', blend: 'overlay', seed: 2 },
{ gen: 'noise', scale: 3, octaves: 3, contrast: 2.2, intensity: 0.8, color: '#8a5a2b', color2: '#d6a566', blend: 'multiply', seed: 8 },
] },
kraft: { doc: 'brown packing paper',
layers: [
{ gen: 'noise', scale: 1.4, octaves: 3, contrast: 1.4, stretch: [6, 1], intensity: 0.35, color: '#000', color2: '#fff', blend: 'overlay', seed: 2 },
{ gen: 'noise', scale: 60, octaves: 3, contrast: 1.3, intensity: 0.25, color: '#6b4a2a', color2: '#d1a874', blend: 'multiply', seed: 7 },
] },
felt: { doc: 'soft wool felt',
layers: [
{ gen: 'noise', scale: 1, octaves: 4, contrast: 1.2, intensity: 0.45, color: '#000', color2: '#fff', blend: 'overlay', seed: 3 },
{ gen: 'noise', scale: 40, octaves: 3, contrast: 1.2, intensity: 0.2, color: '#000', color2: '#fff', blend: 'soft-light', seed: 9 },
] },
burlap: { doc: 'coarse sacking',
layers: [
{ gen: 'weave', scale: 7, thickness: 2.2, intensity: 0.55, color: '#3b2a14', blend: 'multiply' },
{ gen: 'noise', scale: 30, octaves: 3, contrast: 1.5, stretch: [6, 1], intensity: 0.3, color: '#000', color2: '#fff', blend: 'overlay', seed: 4 },
] },
stucco: { doc: 'rough plaster',
layers: [
{ gen: 'bump', scale: 9, octaves: 3, depth: 3.6, light: 135, intensity: 0.7, color: '#000', color2: '#fff', blend: 'overlay', seed: 6 },
{ gen: 'noise', scale: 120, octaves: 3, contrast: 1.2, intensity: 0.2, color: '#000', color2: '#fff', blend: 'soft-light', seed: 1 },
] },
// ---- 0.16.0: weather and terrain
starfield: { doc: 'a night sky of small and large stars',
layers: [
{ gen: 'starfield', scale: 300, thickness: 2.4, density: 0.25, intensity: 1, color: '#cfe3ff', seed: 9, blend: 'normal' },
{ gen: 'starfield', scale: 240, thickness: 1.2, density: 1.3, intensity: 0.9, color: '#ffffff', seed: 5, blend: 'normal' },
] },
snow: { doc: 'fresh snow with a few sparkles',
layers: [
{ gen: 'noise', scale: 1.2, octaves: 1, contrast: 16, threshold: 0.72, intensity: 1, color: 'rgba(255,255,255,0)', color2: '#fff', blend: 'screen', seed: 5 },
{ gen: 'noise', scale: 90, octaves: 4, contrast: 1.4, intensity: 0.5, color: '#9fb6d6', color2: '#ffffff', blend: 'soft-light', seed: 3 },
] },
grass: { doc: 'blades of grass seen from above',
layers: [{ gen: 'noise', scale: 2, octaves: 4, contrast: 2, stretch: [1, 9], intensity: 0.9, color: '#2f6b2a', color2: '#8cc152', blend: 'multiply', seed: 4 }] },
water: { doc: 'water with light ripples',
layers: [
{ gen: 'waves', scale: 56, thickness: 1.6, intensity: 0.22, color: '#ffffff', blend: 'normal' },
{ gen: 'noise', scale: 100, octaves: 4, contrast: 1.5, intensity: 0.8, color: '#0a3d62', color2: '#3c9bd6', blend: 'multiply', seed: 6 },
] },
rain: { doc: 'slanted rain streaks',
layers: [{ gen: 'lines', angle: 100, scale: 14, thickness: 1, softness: 0.6, intensity: 0.35, color: '#ffffff', blend: 'normal' }] },
lava: { doc: 'glowing cracks in dark rock',
layers: [
{ gen: 'noise', scale: 90, octaves: 4, contour: 0.05, threshold: 0.5, intensity: 1, color: '#ffb300', color2: 'rgba(255,60,0,0)', blend: 'screen', seed: 7 },
{ gen: 'noise', scale: 60, octaves: 5, contrast: 1.8, intensity: 0.9, color: '#1a0500', color2: '#7a1a00', blend: 'multiply', seed: 3 },
] },
topo: { doc: 'contour lines, like a map',
layers: [
{ gen: 'noise', scale: 140, octaves: 4, contour: 0.02, threshold: 0.4, intensity: 0.35, color: '#000', color2: 'rgba(0,0,0,0)', blend: 'normal', seed: 8 },
{ gen: 'noise', scale: 140, octaves: 4, contour: 0.02, threshold: 0.5, intensity: 0.5, color: '#000', color2: 'rgba(0,0,0,0)', blend: 'normal', seed: 8 },
{ gen: 'noise', scale: 140, octaves: 4, contour: 0.02, threshold: 0.6, intensity: 0.35, color: '#000', color2: 'rgba(0,0,0,0)', blend: 'normal', seed: 8 },
] },
};
export const PRESET_NAMES = Object.keys(PRESETS);
/** baseline settings per generator, used when you start from a bare generator name */
const GEN_DEFAULTS = {
noise: { scale: 3, octaves: 3, contrast: 1.5, intensity: 0.5, color: '#000', color2: '#fff', blend: 'overlay', type: 'fractal' },
bump: { scale: 6, octaves: 4, depth: 3, light: 135, intensity: 0.6, color: '#000', color2: '#fff', blend: 'overlay' },
lines: { scale: 8, thickness: 1, angle: 45, intensity: 0.3, color: '#000', blend: 'normal' },
dots: { scale: 12, radius: 0.2, softness: 0.1, intensity: 0.4, color: '#000', blend: 'normal' },
grid: { scale: 24, thickness: 1, intensity: 0.25, color: '#000', blend: 'normal' },
checker: { scale: 24, intensity: 0.15, color: '#000', blend: 'normal' },
weave: { scale: 4, thickness: 1, intensity: 0.4, color: '#000', blend: 'multiply' },
carbon: { scale: 20, intensity: 1, color: '#151515', color2: '#262626', blend: 'normal' },
waves: { scale: 40, thickness: 1.2, intensity: 0.3, color: '#000', blend: 'normal' },
hex: { scale: 28, thickness: 1, intensity: 0.28, color: '#000', blend: 'normal' },
bricks: { scale: 48, thickness: 3, intensity: 0.55, color: '#2a1d16', blend: 'normal' },
herringbone: { scale: 22, thickness: 1.5, intensity: 1, color: '#2a1a0e', color2: '#b9854d', seed: 4, blend: 'normal' },
plaid: { scale: 64, thickness: 2, intensity: 0.5, color: '#9b1b1b', blend: 'multiply' },
scales: { scale: 36, thickness: 1.2, intensity: 0.5, color: '#000', blend: 'normal' },
chevron: { scale: 40, thickness: 6, intensity: 0.35, color: '#000', blend: 'normal' },
diamonds: { scale: 40, thickness: 1.2, intensity: 0.4, color: '#000', blend: 'normal' },
triangles: { scale: 60, intensity: 0.5, color: '#000', color2: '#fff', seed: 3, blend: 'overlay' },
starfield: { scale: 240, thickness: 1.4, density: 1, intensity: 1, color: '#ffffff', seed: 5, blend: 'normal' },
terrazzo: { scale: 240, density: 1, intensity: 0.9, color: '#c9a98f', color2: '#6b7a8f', seed: 6, blend: 'normal' },
rings: { scale: 10, color: '#7a4b24', color2: '#d1a770', contrast: 1.2, figure: 0.5, warp: 0.4, pores: 0.4, intensity: 1, blend: 'normal' },
};
/* ------------------------------------------------------------------ *
* parsing (compile time)
* ------------------------------------------------------------------ */
/** split on top-level commas ("grain, scanlines" or "rgb(1, 2, 3)" stays whole) */
function splitCommas(s) {
const out = []; let depth = 0, cur = '';
for (const ch of s) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out;
}
/** split on top-level whitespace, keeping rgb(1 2 3) and color.ink/40 as one word */
export function splitWords(s) {
const out = []; let depth = 0, cur = '';
for (const ch of s.trim()) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (/\s/.test(ch) && depth === 0) { if (cur) out.push(cur); cur = ''; continue; }
cur += ch;
}
if (cur) out.push(cur);
return out;
}
/**
* Parse the value of `texture:`.
* hooks.error(message, hint) report a problem
* hooks.color(word) turn a colour word into the CSS the engine will resolve later
* hooks.suggest(word, list) did-you-mean
* Returns an array of layer specs, [] for `none`, or null when it was invalid.
*/
export function parseTexture(value, hooks) {
const text = String(value || '').trim();
if (!text) { hooks.error('texture needs a name', `try texture: grain, or one of ${PRESET_NAMES.slice(0, 6).join(', ')} ...`); return null; }
if (text === 'none') return [];
const out = [];
for (const part of splitCommas(text)) {
const words = splitWords(part);
const first = words[0];
let base;
if (PRESETS[first]) base = PRESETS[first].layers.map((l) => ({ ...l, preset: first }));
else if (GENERATORS.includes(first)) base = [{ gen: first, preset: null, ...GEN_DEFAULTS[first] }];
else {
hooks.error(`unknown texture "${first}"`, hooks.suggest(first, [...PRESET_NAMES, ...GENERATORS]) || `presets: ${PRESET_NAMES.join(', ')}. generators: ${GENERATORS.join(', ')}`);
return null;
}
const over = {};
for (let i = 1; i < words.length; i++) {
const key = words[i];
const def = TEXTURE_KEYS[key];
if (!def) {
hooks.error(`unknown texture setting "${key}"`, hooks.suggest(key, Object.keys(TEXTURE_KEYS)) || `settings: ${Object.keys(TEXTURE_KEYS).join(', ')}`);
return null;
}
if (def.kind === 'flag') { over[key] = true; continue; }
// a setting whose value is optional (symmetrical alone means both)
if (def.optional && (words[i + 1] === undefined || TEXTURE_KEYS[words[i + 1]] || !def.values.includes(words[i + 1]))) { over[key] = def.values[0]; continue; }
const vals = words.slice(i + 1, i + 1 + def.words);
if (vals.length < def.words || vals.some((v) => TEXTURE_KEYS[v])) {
hooks.error(`texture setting "${key}" needs ${def.words === 1 ? 'a value' : def.words + ' values'}`, def.doc);
return null;
}
i += def.words;
if (def.kind === 'number' || def.kind === 'numbers') {
const nums = vals.map((v) => Number(v));
if (nums.some((n) => Number.isNaN(n))) { hooks.error(`texture setting "${key}" needs a number, got "${vals.join(' ')}"`, def.doc); return null; }
if (nums.some((n) => n < def.min || n > def.max)) { hooks.error(`texture "${key}" must be between ${def.min} and ${def.max}`, def.doc); return null; }
over[key] = def.kind === 'numbers' ? nums : (def.int ? Math.round(nums[0]) : nums[0]);
} else if (def.kind === 'word') {
if (!def.values.includes(vals[0])) {
hooks.error(`unknown ${key} "${vals[0]}"`, hooks.suggest(vals[0], def.values) || `use one of: ${def.values.join(', ')}`);
return null;
}
over[key] = vals[0];
} else if (def.kind === 'color') {
const css = hooks.color(vals[0]);
if (css === null) return null;
over[key] = css;
}
}
// settings given in a custom texture apply to every layer of a multi-layer preset
// (color settings apply to the first layer only when the preset has several, so a
// two-layer preset keeps its own second tone)
base.forEach((layer, idx) => {
const merged = { ...layer };
for (const [k, v] of Object.entries(over)) {
if ((k === 'color' || k === 'color2') && base.length > 1 && idx > 0) continue;
merged[k] = v;
}
out.push(merged);
});
}
return out;
}
/* ------------------------------------------------------------------ *
* building (run time, in the browser, or in tests)
* ------------------------------------------------------------------ */
const svgUrl = (svg) => `url("data:image/svg+xml,${encodeURIComponent(svg).replace(/'/g, '%27')}")`;
const f = (n, d = 3) => String(+Number(n).toFixed(d));
const n01 = (x) => Math.max(0, Math.min(1, x));
/** resolve a layer's two colours into rgba objects with intensity applied */
function layerColors(spec, rc) {
let a = rc(spec.color) || parseColor('#000');
let b = rc(spec.color2) || (spec.color2 === undefined ? null : parseColor('#fff'));
const k = spec.intensity === undefined ? 1 : spec.intensity;
if (spec.invert && b) [a, b] = [b, a];
const scaleA = (c) => (c ? withAlpha(c, n01(c.a * k)) : null);
return { a: scaleA(a), b: scaleA(b), raw: { a, b } };
}
function noiseFreq(spec) {
const base = 1 / Math.max(0.2, spec.scale || 3);
const st = spec.stretch || [1, 1];
let fx = base / (st[0] || 1), fy = base / (st[1] || 1);
if (spec.angle === 90 || spec.angle === -90 || spec.angle === 270) [fx, fy] = [fy, fx];
return [Math.max(0.0008, fx), Math.max(0.0008, fy)];
}
const tileFor = (spec) => {
const f2 = noiseFreq(spec);
// keep tiles reasonably small, but big enough to hide repetition for coarse noise
const feature = 1 / Math.min(f2[0], f2[1]);
return Math.round(Math.max(128, Math.min(512, feature * 3)));
};
/** a lookup table that is 0 right at `level` and rises to 1 by `width` away: contour lines */
function contourTable(level, width) {
const n = 96, out = [];
for (let i = 0; i < n; i++) out.push(f(Math.min(1, Math.abs(i / (n - 1) - level) / width), 3));
return out.join(' ');
}
/** noise → contrast curve → two-colour ramp. The workhorse. */
function buildNoise(spec, rc) {
const { a, b } = layerColors(spec, rc);
const A = a, B = b || a;
const [fx, fy] = noiseFreq(spec);
const oct = Math.max(1, Math.round(spec.octaves || 3));
const c = spec.contrast === undefined ? 1.5 : spec.contrast;
const t = spec.threshold === undefined ? 0.5 : spec.threshold;
const off = 0.5 - c * t;
const T = tileFor(spec);
const ch = (x) => f(x / 255);
const svg =
``;
return [{ image: svgUrl(svg), size: `${T}px ${T}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'overlay', tile: T }];
}
/**
* Noise used as a height map and lit from one side, so it reads as physical
* relief. The lighting is a 3x3 directional-derivative convolution with
* edgeMode="wrap", which keeps the tile seamless. feDiffuseLighting looks
* nicer up close but treats the tile edge specially and leaves a 1px seam.
*/
function buildBump(spec, rc) {
const { a, b } = layerColors(spec, rc);
const A = a, B = b || a;
const [fx, fy] = noiseFreq(spec);
const oct = Math.max(1, Math.round(spec.octaves || 4));
const T = tileFor(spec);
const az = ((spec.light === undefined ? 135 : spec.light) * Math.PI) / 180;
// relief strength grows slowly with feature size: big features have gentle slopes, but the fine
// octaves layered on top keep their steep ones, so a linear rule over-drives anything coarse
const m = (spec.depth === undefined ? 3 : spec.depth) * 0.33 * Math.pow(Math.max(0.5, spec.scale || 6), 0.25);
const gx = [-1, 0, 1, -2, 0, 2, -1, 0, 1], gy = [-1, -2, -1, 0, 0, 0, 1, 2, 1];
const kernel = gx.map((v, i) => f((m * (Math.cos(az) * v + Math.sin(az) * gy[i])) / 4, 4)).join(' ');
const ch = (x) => f(x / 255);
const svg =
``;
return [{ image: svgUrl(svg), size: `${T}px ${T}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'overlay', tile: T }];
}
function buildLines(spec, rc) {
const { a, b } = layerColors(spec, rc);
const p = Math.max(1, spec.scale || 8), w = Math.min(p, Math.max(0.2, spec.thickness || 1));
const soft = (spec.softness === undefined ? 0.4 : spec.softness) * 1.5;
const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
const ang = spec.angle === undefined ? 45 : spec.angle;
const image = `repeating-linear-gradient(${f(-ang, 1)}deg, ${ink} 0, ${ink} ${f(w)}px, ${bg} ${f(w + soft)}px, ${bg} ${f(p)}px)`;
return [{ image, size: 'auto', repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: p }];
}
function buildDots(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(2, spec.scale || 12), r = (spec.radius === undefined ? 0.2 : spec.radius) * S;
const soft = Math.max(0.3, (spec.softness === undefined ? 0.1 : spec.softness) * S);
const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
const dot = `radial-gradient(circle at center, ${ink} 0, ${ink} ${f(Math.max(0, r - soft / 2))}px, ${bg} ${f(r + soft / 2)}px, ${bg} 100%)`;
const layers = [{ image: dot, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S }];
if (spec.stagger) layers.push({ image: dot, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: `${f(S / 2)}px ${f(S / 2)}px`, blend: spec.blend || 'normal', tile: S });
return layers;
}
function buildGrid(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(3, spec.scale || 24), w = Math.max(0.3, spec.thickness || 1);
const ink = toRgba(a);
const blend = spec.blend || 'normal';
const out = [
{ image: `linear-gradient(${ink} ${f(w)}px, transparent 0)`, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend, tile: S },
{ image: `linear-gradient(90deg, ${ink} ${f(w)}px, transparent 0)`, size: `${f(S)}px ${f(S)}px`, repeat: 'repeat', position: '0 0', blend, tile: S },
];
if (b) out.push({ image: `linear-gradient(${toRgba(b)}, ${toRgba(b)})`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: S });
return out;
}
function buildChecker(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(2, spec.scale || 24);
const ink = toRgba(a), bg = b ? toRgba(b) : 'transparent';
const image = `conic-gradient(${ink} 25%, ${bg} 0 50%, ${ink} 0 75%, ${bg} 0)`;
return [{ image, size: `${f(S * 2)}px ${f(S * 2)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S * 2 }];
}
/** plain-weave threads: horizontal and vertical fine lines */
function buildWeave(spec, rc) {
const { a } = layerColors(spec, rc);
const p = Math.max(1.5, spec.scale || 4), w = Math.max(0.4, spec.thickness || 1);
const ink = toRgba(a), clear = toRgba(withAlpha(a.r === undefined ? parseColor('#000') : a, 0));
const blend = spec.blend || 'multiply';
return [
{ image: `repeating-linear-gradient(0deg, ${ink} 0, ${ink} ${f(w)}px, ${clear} ${f(w + 0.6)}px, ${clear} ${f(p)}px)`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: p },
{ image: `repeating-linear-gradient(90deg, ${ink} 0, ${ink} ${f(w)}px, ${clear} ${f(w + 0.6)}px, ${clear} ${f(p)}px)`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: p },
];
}
/** the classic layered-gradient carbon fibre, recoloured from color and color2 */
function buildCarbon(spec, rc) {
const { a, b } = layerColors(spec, rc);
const A = a, B = b || a;
const dark = toRgba(A), light = toRgba(B);
const mid = toRgba(mix2(A, B, 0.5)), deep = toRgba(mix2(A, parseColor('#000'), 0.25));
const S = Math.max(8, spec.scale || 20), h = S / 2, q = S / 4;
const blend = spec.blend || 'normal';
const sz = `${f(S)}px ${f(S)}px`;
const L = (image, size, position) => ({ image, size, repeat: 'repeat', position, blend, tile: S });
return [
L(`linear-gradient(27deg, ${dark} ${f(q)}px, transparent ${f(q)}px)`, sz, `0 ${f(q)}px`),
L(`linear-gradient(207deg, ${dark} ${f(q)}px, transparent ${f(q)}px)`, sz, `${f(h)}px 0`),
L(`linear-gradient(27deg, ${light} ${f(q)}px, transparent ${f(q)}px)`, sz, `0 ${f(h)}px`),
L(`linear-gradient(207deg, ${light} ${f(q)}px, transparent ${f(q)}px)`, sz, `${f(h)}px ${f(q)}px`),
L(`linear-gradient(90deg, ${mid} ${f(h)}px, transparent ${f(h)}px)`, sz, '0 0'),
L(`linear-gradient(${light} 25%, ${dark} 25%, ${dark} 50%, transparent 50%, transparent 75%, ${mid} 75%, ${mid})`, sz, '0 0'),
L(`linear-gradient(${deep}, ${deep})`, 'auto', '0 0'),
];
}
function buildWaves(spec, rc) {
const { a } = layerColors(spec, rc);
const S = Math.max(8, spec.scale || 40), w = Math.max(0.3, spec.thickness || 1.2);
const col = toRgba(a);
const svg = ``;
return [{ image: svgUrl(svg), size: `${f(S)}px ${f(S / 2)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: S }];
}
function buildHex(spec, rc) {
const { a } = layerColors(spec, rc);
const W = Math.max(14, spec.scale || 28), k = W / 56;
const col = toRgba(a), sw = Math.max(0.3, spec.thickness || 1) / k;
const svg = ``;
return [{ image: svgUrl(svg), size: `${f(W)}px ${f(100 * k)}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: W }];
}
/* ------------------------------------------------------------------ *
* rings: growth rings, drawn as vector curves
*
* A board is a log sliced, and the rings you see are where the slice crosses
* the growth layers. So this does not stretch noise: it draws each ring as a
* curve. The spacing wobbles, the curves arch (cathedral figure) because the
* slice cuts the layers at a slant, there are pores between them, and planks
* and knots are drawn on top. Everything comes from a seeded generator and
* integer harmonics, so the same seed gives the same board on every machine
* and the tile has no visible seam.
* ------------------------------------------------------------------ */
const TAU = Math.PI * 2;
const r1 = (n) => String(Math.round(n * 10) / 10);
const smallUrl = (svg) => `url("data:image/svg+xml,${svg.replace(/[%#<>"]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())}")`;
/** a smooth path through [x, y] samples (Catmull-Rom as cubic Beziers, ends clamped) */
function smoothPath(pts) {
const n = pts.length;
const at = (i) => pts[Math.max(0, Math.min(n - 1, i))];
let d = `M${r1(pts[0][0])} ${r1(pts[0][1])}`;
for (let i = 0; i < n - 1; i++) {
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
d += `C${r1(c1x)} ${r1(c1y)} ${r1(c2x)} ${r1(c2y)} ${r1(p2[0])} ${r1(p2[1])}`;
}
return d;
}
function buildRings(spec, rc) {
const { a, b } = layerColors(spec, rc);
const late = a, early = b || a;
const P = Math.max(3, spec.scale || 10);
const W = 576; // one tile along the grain; every harmonic below divides it
const fig = spec.figure === undefined ? 0.5 : spec.figure;
const warp = spec.warp === undefined ? 0.4 : spec.warp;
const pores = spec.pores === undefined ? 0.4 : spec.pores;
const contrast = spec.contrast === undefined ? 1.2 : spec.contrast;
const seed = spec.seed || 0;
const planked = !!spec.planks;
const plankH = planked ? Math.max(P * 3, spec.planks) : 0;
const plankN = planked ? 3 : 1;
const H = planked ? plankH * plankN : P * Math.max(12, Math.round(260 / P));
const rgba = (c, alpha) => toRgba(withAlpha(c, n01((alpha === undefined ? 1 : alpha) * c.a)));
const STEP = 48;
let defs = '', body = '';
let id = 0;
for (let pi = 0; pi < plankN; pi++) {
const rand = rng(seed * 7919 + pi * 104729 + 13);
const h = planked ? plankH : H;
const n = Math.max(3, Math.round(h / P));
const Pe = h / n;
const ph = [rand() * TAU, rand() * TAU, rand() * TAU];
const x0 = rand() * W;
const m = 2 + Math.floor(rand() * 3), spacingAmp = 0.12 + rand() * 0.22;
const flip = planked && rand() < 0.5;
const curve = (kf, x) => {
const t = kf / n;
// a free board arches from one face to the other; a seamless tile must repeat every n rings, so its envelope has period n
const per = Math.pow(Math.sin(Math.PI * t), 2);
const env = planked ? Math.pow(Math.max(0, Math.min(1, flip ? 1 - t : t)), 1.4) : per;
const env2 = planked ? 0.6 + 0.4 * Math.abs(Math.sin(Math.PI * t)) : per;
const bump = Math.pow((1 + Math.cos(TAU * (x - x0) / W)) / 2, 1.3);
const arch = fig * 0.3 * h * env * bump;
const wob = warp * P * 0.6 * env2 * (0.5 * Math.sin(TAU * x / W + ph[0] + TAU * kf / n) + 0.3 * Math.sin(TAU * 3 * x / W + ph[1] + TAU * 2 * kf / n) + 0.2 * Math.sin(TAU * 7 * x / W + ph[2] + TAU * 3 * kf / n));
const u = kf + spacingAmp * Math.sin(TAU * m * kf / n);
return u * Pe + arch + wob;
};
// sampled past both ends of the tile, so the stroke is continuous across the join and the viewport clips it
const ringPath = (kf) => { const pts = []; for (let x = -2 * STEP; x <= W + 2 * STEP; x += STEP) pts.push([x, curve(kf, x)]); return smoothPath(pts); };
const jit = Array.from({ length: n }, () => [rand(), rand()]);
const lo = planked ? -2 : -1, hi = planked ? n + 2 : n;
let g = '';
for (let k = lo; k <= hi; k++) {
const [jitter, jop] = jit[((k % n) + n) % n]; // ring k and ring k + n look alike, so the tile repeats cleanly
const w = Pe * (0.14 + jitter * 0.26);
const op = n01((0.4 + jop * 0.55) * Math.min(1.4, contrast / 1.2));
const rid = 'r' + id++;
defs += ``;
g += ``;
}
// knots: elongated along the grain, with rings around them
const knotN = spec.knots ? Math.max(0, Math.round(spec.knots / plankN + (pi === 0 ? 0.49 : 0))) : 0;
let knots = '';
for (let q = 0; q < knotN; q++) {
const kx = 90 + rand() * (W - 180), ky = h * (0.25 + rand() * 0.5), rx = 9 + rand() * 12, ry = rx * 0.6;
knots += `` +
`` +
`` +
``;
}
const tone = (rand() - 0.5) * 0.14;
const toneRect = tone > 0 ? `` : ``;
const clip = `c${pi}`;
defs += ``;
body += `` +
`` + (planked ? toneRect : '') + `${g}${knots}`;
}
// fine fibres: streaks stretched along the grain, drawn once over everything
const fibre = pores > 0
? `` +
`` +
`` +
``
: '';
let seams = '';
if (planked) {
for (let pi = 0; pi < plankN; pi++) {
const y = pi * plankH;
seams += ``;
}
}
const rot = spec.angle === 90 || spec.angle === -90 || spec.angle === 270;
const inner = `${defs}${body}${fibre}${seams}`;
const svg = rot
? ``
: ``;
return [{ image: smallUrl(svg), size: rot ? `${H}px ${W}px` : `${W}px ${H}px`, repeat: 'repeat', position: '0 0', blend: spec.blend || 'normal', tile: Math.max(W, H) }];
}
/* ------------------------------------------------------------------ *
* 0.16.0: patterns drawn as small vector tiles (so a build keeps them as plain CSS)
* ------------------------------------------------------------------ */
const tile = (W, H, inner) => ``;
const piece = (svg, W, H, blend) => [{ image: svgUrl(svg), size: `${f(W)}px ${f(H)}px`, repeat: 'repeat', position: '0 0', blend: blend || 'normal', tile: Math.max(W, H) }];
const darken = (c, t) => mix2(c, parseColor('#000'), t);
/** running-bond bricks: color is the mortar, color2 (optional) fills the bricks */
function buildBricks(spec, rc) {
const { a, b } = layerColors(spec, rc);
const W = Math.max(12, spec.scale || 48), H = W / 2, t = Math.max(0.5, spec.thickness || 3);
const seams = `M0 0H${f(W)}M0 ${f(H)}H${f(W)}M0 ${f(2 * H)}H${f(W)}M0 0V${f(H)}M${f(W)} 0V${f(H)}M${f(W / 2)} ${f(H)}V${f(2 * H)}`;
const fill = b ? `` : '';
return piece(tile(W, 2 * H, `${fill}`), W, 2 * H, spec.blend);
}
/** herringbone parquet: planks laid in a zigzag, each a slightly different shade. color is the seam, color2 the board. */
function buildHerringbone(spec, rc) {
const { a, b } = layerColors(spec, rc);
const w = Math.max(6, spec.scale || 22), t = Math.max(0.3, spec.thickness || 1.5);
const board = b || parseColor('#b9854d'), rand = rng((spec.seed || 4) + 101);
// the base cell (two planks) repeats every sqrt(2)w across and 2*sqrt(2)w down; the tile holds N1 x N2 of them so the planks can differ in
// shade, and a plank's shade depends only on where it sits modulo the tile, so the tile repeats without a seam
const N1 = 3, N2 = 2, TW = Math.SQRT2 * w * N1, TH = 2 * Math.SQRT2 * w * N2;
const shades = Array.from({ length: 2 * N2 }, () => Array.from({ length: N1 }, () => toRgba(darken(board, 0.14 * rand()))));
let planks = '';
for (let s = -12; s <= 12; s++) for (let k = -14; k <= 14; k++) {
const x = (k - s) * w, y = (k + s) * w, horizontal = (((k % 2) + 2) % 2) === 0;
const pw = horizontal ? 2 * w : w, ph = horizontal ? w : 2 * w;
const mx = (x + pw / 2 - (y + ph / 2)) / Math.SQRT2, my = (x + pw / 2 + (y + ph / 2)) / Math.SQRT2; // the plank's middle, once turned
if (mx < -2.5 * w || mx > TW + 2.5 * w || my < -2.5 * w || my > TH + 2.5 * w) continue; // only what reaches the tile
const shade = shades[((k % (2 * N2)) + 2 * N2) % (2 * N2)][((s % N1) + N1) % N1];
planks += ``;
}
const svg = tile(TW, TH, `${planks}`);
return piece(svg, TW, TH, spec.blend);
}
/** tartan: wide soft bands one way and the other, crossed, with a thin line in each; color2 (optional) is the ground */
function buildPlaid(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(10, spec.scale || 64), band = S * (spec.radius === undefined ? 0.3 : spec.radius), line = Math.max(0.5, spec.thickness || 2);
const ink = toRgba(a), clear = toRgba(withAlpha(a, 0)), dark = toRgba(withAlpha(darken(a, 0.45), a.a));
const blend = spec.blend || 'multiply';
const out = [];
for (const deg of [0, 90]) {
out.push({ image: `repeating-linear-gradient(${deg}deg, ${ink} 0, ${ink} ${f(band)}px, ${clear} ${f(band)}px, ${clear} ${f(S)}px)`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: S });
out.push({ image: `repeating-linear-gradient(${deg}deg, transparent 0, transparent ${f(S * 0.55)}px, ${dark} ${f(S * 0.55)}px, ${dark} ${f(S * 0.55 + line)}px, transparent ${f(S * 0.55 + line)}px, transparent ${f(S)}px)`, size: 'auto', repeat: 'repeat', position: '0 0', blend, tile: S });
}
if (b) out.push({ image: `linear-gradient(${toRgba(b)}, ${toRgba(b)})`, size: 'auto', repeat: 'repeat', position: '0 0', blend: 'normal', tile: S });
return out;
}
/** fish scales / roof shingles: overlapping arcs. color2 (optional) fills each scale. */
function buildScales(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(10, spec.scale || 36), t = Math.max(0.3, spec.thickness || 1.2), r = S / 2;
const fill = b ? toRgba(b) : 'none';
const circle = (cx, cy) => ``;
const svg = tile(S, r, `${circle(0, 0)}${circle(S, 0)}${circle(S / 2, r)}`);
return piece(svg, S, r, spec.blend);
}
/** zigzag bands */
function buildChevron(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(8, spec.scale || 40), t = Math.max(0.5, spec.thickness || 6);
const bg = b ? `` : '';
const zig = (y) => ``;
const svg = tile(S, S, `${bg}${zig(S / 4)}${zig(S * 0.75)}${zig(S * 1.25)}${zig(-S / 4)}`);
return piece(svg, S, S, spec.blend);
}
/** a diamond lattice (argyle); color2 (optional) fills the diamonds */
function buildDiamonds(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(8, spec.scale || 40), t = Math.max(0.3, spec.thickness || 1.2);
const d = `M${f(S / 2)} 0L${f(S)} ${f(S / 2)}L${f(S / 2)} ${f(S)}L0 ${f(S / 2)}Z`;
const svg = tile(S, S, ``);
return piece(svg, S, S, spec.blend);
}
/** low-poly facets: a grid of triangles, each shaded somewhere between color and color2 */
function buildTriangles(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(16, spec.scale || 60), n = 4, c = S / n, rand = rng((spec.seed || 3) + 211);
const lo = a, hi = b || parseColor('#fff');
let tris = '';
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) {
const x0 = x * c, y0 = y * c, flip = (x + y) % 2 === 0;
const A = flip ? [[x0, y0], [x0 + c, y0], [x0, y0 + c]] : [[x0, y0], [x0 + c, y0 + c], [x0, y0 + c]];
const B = flip ? [[x0 + c, y0], [x0 + c, y0 + c], [x0, y0 + c]] : [[x0, y0], [x0 + c, y0], [x0 + c, y0 + c]];
for (const tri of [A, B]) tris += ``;
}
return piece(tile(S, S, tris), S, S, spec.blend);
}
/** scattered stars, some with a little cross. The tile is random, so it has no seam and no visible repeat at normal sizes. */
function buildStarfield(spec, rc) {
const { a } = layerColors(spec, rc);
const S = Math.max(60, spec.scale || 240), rmax = Math.max(0.4, spec.thickness || 1.4), rand = rng((spec.seed || 5) + 307);
const count = Math.round(((S * S) / 700) * (spec.density === undefined ? 1 : spec.density));
let stars = '';
for (let i = 0; i < count; i++) {
const x = rand() * S, y = rand() * S, big = rand();
const r = rmax * (0.25 + 0.75 * big * big), o = 0.35 + 0.65 * rand();
stars += ``;
if (big > 0.93) stars += ``;
}
return piece(tile(S, S, stars), S, S, spec.blend);
}
/** chips of stone in a few colours, scattered: terrazzo. Draws only the chips (no ground), so it lays over any background. */
function buildTerrazzo(spec, rc) {
const { a, b } = layerColors(spec, rc);
const S = Math.max(60, spec.scale || 240), rand = rng((spec.seed || 6) + 401);
const count = Math.round(((S * S) / 450) * (spec.density === undefined ? 1 : spec.density));
const palette = [a, b || darken(a, 0.35), mix2(a, b || parseColor('#fff'), 0.5), darken(a, 0.25), mix2(b || a, parseColor('#fff'), 0.4)];
let chips = '';
for (let i = 0; i < count; i++) {
const cx = rand() * S, cy = rand() * S, r = 2.5 + rand() * rand() * 11, rot = rand() * TWO, k = 5 + ((rand() * 3) | 0);
const pts = [];
for (let j = 0; j < k; j++) { const ang = rot + (j / k) * TWO, rr = r * (0.6 + 0.4 * rand()); pts.push(`${f(cx + Math.cos(ang) * rr, 1)},${f(cy + Math.sin(ang) * rr * 0.8, 1)}`); }
chips += ``;
}
return piece(tile(S, S, chips), S, S, spec.blend);
}
const TWO = Math.PI * 2;
const BUILDERS = { bricks: buildBricks, herringbone: buildHerringbone, plaid: buildPlaid, scales: buildScales, chevron: buildChevron, diamonds: buildDiamonds, triangles: buildTriangles, starfield: buildStarfield, terrazzo: buildTerrazzo, rings: buildRings, noise: buildNoise, bump: buildBump, lines: buildLines, dots: buildDots, grid: buildGrid, checker: buildChecker, weave: buildWeave, carbon: buildCarbon, waves: buildWaves, hex: buildHex };
/**
* Turn parsed layers into the CSS lists for background-image, -size, -repeat,
* -position and -blend-mode, top layer first.
* rc(cssColorWord) -> {r,g,b,a} | null
*/
/**
* symmetrical: lay the tile out twice over, the second copy flipped, so every edge meets its own mirror image.
* That is seamless for any image at all, and the pattern now repeats at double the distance. Only the
* SVG-image textures can be flipped this way; the pure CSS ones (lines, dots, grid, checker, carbon) are
* already regular and are left alone.
*/
function mirrorPiece(piece, mode) {
const m = /^url\("(data:image\/svg\+xml,[^"]*)"\)$/.exec(piece.image);
const sz = /^([\d.]+)px ([\d.]+)px$/.exec(piece.size || '');
if (!m || !sz) return piece;
const w = Number(sz[1]), h = Number(sz[2]);
const fx = mode === 'x' || mode === 'both', fy = mode === 'y' || mode === 'both';
const W = fx ? w * 2 : w, H = fy ? h * 2 : h;
const use = (tx, ty, sx, sy) => ``;
let body = use(0, 0, 1, 1);
if (fx) body += use(2 * w, 0, -1, 1);
if (fy) body += use(0, 2 * h, 1, -1);
if (fx && fy) body += use(2 * w, 2 * h, -1, -1);
const inner = m[1].replace(/'/g, '%27');
const svg = ``;
return { ...piece, image: smallUrl(svg), size: `${W}px ${H}px`, tile: Math.max(piece.tile || 0, W, H) };
}
export function buildTexture(layers, rc) {
const parts = [];
for (const spec of layers) {
const builder = BUILDERS[spec.gen];
if (!builder) continue;
for (const piece of builder(spec, rc)) parts.push({ ...(spec.symmetrical ? mirrorPiece(piece, spec.symmetrical) : piece), animate: spec.animate || 0 });
}
return {
image: parts.map((p) => p.image).join(', '),
size: parts.map((p) => p.size).join(', '),
repeat: parts.map((p) => p.repeat).join(', '),
position: parts.map((p) => p.position).join(', '),
blend: parts.map((p) => p.blend).join(', '),
count: parts.length,
parts,
};
}
/* ------------------------------------------------------------------ *
* composing with the element's own background (shared by the engine and `dssl build`)
* ------------------------------------------------------------------ */
export function splitBgList(value) {
const out = []; let depth = 0, cur = '';
for (const ch of String(value)) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (ch === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue; }
cur += ch;
}
if (cur.trim()) out.push(cur.trim());
return out;
}
/** a list of exactly n entries: the author's, cycled the way CSS cycles them, or the default */
function expandList(value, n, dflt) {
const items = value ? splitBgList(value) : [];
if (!items.length) return Array.from({ length: n }, () => dflt);
return Array.from({ length: n }, (_, i) => items[i % items.length]);
}
/**
* texture sits ON TOP of whatever background the author wrote: the shorthand, the longhands,
* or just a colour. `owned` is a Map of prop -> { value, imp }; this edits it in place.
*/
export function composeTextureInto(owned, tex) {
const sh = owned.get('background');
const img = owned.get('background-image');
const blendUser = owned.get('background-blend-mode');
const texBlends = tex.blend.split(', ');
if (sh) {
// the author wrote the shorthand: texture layers go first, their value stays the last layer(s)
const userLayers = splitBgList(sh.value).length;
const texShort = tex.parts.map((p) => `${p.image} ${p.position} / ${p.size} ${p.repeat}`).join(', ');
owned.set('background', { value: `${texShort}, ${sh.value}`, imp: sh.imp });
owned.delete('background-image');
owned.set('background-blend-mode', { value: [...texBlends, ...expandList(blendUser && blendUser.value, userLayers, 'normal')].join(', '), imp: sh.imp });
return;
}
const userLayers = img ? splitBgList(img.value).length : 0;
const imp = img ? img.imp : '';
const get = (name) => (owned.get(name) || {}).value;
const join = (texList, name, dflt) => [...texList.split(/,(?![^(]*\))/).map((x) => x.trim()), ...(userLayers ? expandList(get(name), userLayers, dflt) : [])].join(', ');
owned.set('background-image', { value: tex.image + (img ? ', ' + img.value : ''), imp });
owned.set('background-size', { value: join(tex.size, 'background-size', 'auto'), imp });
owned.set('background-position', { value: join(tex.position, 'background-position', '0% 0%'), imp });
owned.set('background-repeat', { value: join(tex.repeat, 'background-repeat', 'repeat'), imp });
owned.set('background-blend-mode', { value: [...texBlends, ...(userLayers ? expandList(get('background-blend-mode'), userLayers, 'normal') : [])].join(', '), imp });
}
/* ------------------------------------------------------------------ *
* deterministic value noise, for aura masks and tests
* ------------------------------------------------------------------ */
/** small fast seeded PRNG (mulberry32) */
export function rng(seed) {
let a = (seed >>> 0) || 1;
return () => {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* An alpha mask (Uint8Array of size*size) for the textures that make sense as one.
* Used by aura so particles and glow can be grainy, dotted, scanned. Tileable.
*/
export function makeMask(layer, size = 128) {
const out = new Uint8Array(size * size);
const gen = layer.gen;
const k = layer.intensity === undefined ? 1 : Math.min(1, layer.intensity);
if (gen === 'lines') {
const p = Math.max(2, layer.scale || 8), w = layer.thickness || 1, ang = ((layer.angle || 0) * Math.PI) / 180;
// normal to a line whose direction is (cos a, -sin a) in screen coordinates
const c = Math.sin(ang), s = Math.cos(ang);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const d = (((x * c + y * s) % p) + p) % p;
out[y * size + x] = d < w ? 255 * k : 0;
}
return out;
}
if (gen === 'dots') {
const S = Math.max(2, layer.scale || 12), r = (layer.radius === undefined ? 0.2 : layer.radius) * S;
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const dx = (x % S) - S / 2, dy = (y % S) - S / 2;
out[y * size + x] = Math.hypot(dx, dy) < r ? 255 * k : 0;
}
return out;
}
// everything else: value-noise fbm, tiled
const rand = rng((layer.seed || 0) + 17);
const cell = Math.max(1, Math.round(layer.scale || 3));
const gridN = Math.max(2, Math.floor(size / cell));
const lat = new Float32Array(gridN * gridN);
for (let i = 0; i < lat.length; i++) lat[i] = rand();
const oct = Math.max(1, Math.min(6, layer.octaves || 3));
const contrast = layer.contrast === undefined ? 1.5 : layer.contrast;
const thr = layer.threshold === undefined ? 0.5 : layer.threshold;
const smooth = (t) => t * t * (3 - 2 * t);
const sample = (u, v, n) => {
const g = Math.max(2, Math.round(n));
const x = ((u % 1) + 1) % 1 * g, y = ((v % 1) + 1) % 1 * g;
const x0 = Math.floor(x), y0 = Math.floor(y), fx = smooth(x - x0), fy = smooth(y - y0);
const at = (ix, iy) => lat[(((iy % gridN) + gridN) % gridN) * gridN + (((ix % gridN) + gridN) % gridN)];
const a = at(x0, y0), b = at(x0 + 1, y0), c = at(x0, y0 + 1), d = at(x0 + 1, y0 + 1);
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
};
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
let v = 0, amp = 0.5, n = gridN, tot = 0;
for (let o = 0; o < oct; o++) { v += sample(x / size, y / size, n) * amp; tot += amp; amp *= 0.5; n *= 2; }
v /= tot;
const m = Math.max(0, Math.min(1, contrast * (v - thr) + 0.5));
out[y * size + x] = 255 * m * k;
}
return out;
}