/* ========================================================================= * 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 = `` + `` + `` + (spec.contour ? `` + `` : ``) + `` + ``; 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 ? `${inner}` : `${inner}`; 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) => `${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 = `${body}`; 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; }