import { contoursToPathData } from './trace.js'; /** * Hand-drawn distortion for traced contours. * * WHY: `trace.js` recovers the silhouette of the mascot from a rendered alpha * mask. The result is geometrically faithful but *mechanically* smooth: it reads * as the outline of a printed sticker, not as a pen stroke. This module nudges * the traced points along a smooth, seeded wobble so the very same silhouette * looks inked by hand, without changing its point count or where it sits. * * The displacement is value noise interpolated along the contour, so neighbouring * points move by nearly the same amount and the outline stays a wobbly *line* * rather than pixel jitter. Everything is seeded (never `Math.random`), so a * build is reproducible, and the module is pure: it never touches the DOM and * its only import is the path-data helper in `trace.js`, which keeps the output * format identical to the un-roughened export. */ /** Lattice cells in the non-wrapping noise table (the pattern repeats after this). */ const NOISE_PERIOD = 4096; const EPSILON = 1e-9; /** * mulberry32: a tiny, fast 32-bit generator. Good enough for visual noise and, * crucially, fully reproducible; the seed is the only source of variation. * * @param {number} seed * @returns {() => number} values in [0, 1) */ function mulberry32(seed) { let a = seed >>> 0; return function next() { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** Hermite ramp: 0 at t=0, 1 at t=1, flat at both ends (C1 continuity). */ function smoothstep(t) { return t * t * (3 - 2 * t); } /** * A circular table of random values in -1..1. `count` is the number of cells in * one lap; sampling wraps at `count`, which is what lets a closed stroke's * wobble meet itself exactly at the seam. * * @param {number} seed * @param {number} count * @returns {Float64Array} */ function buildNoiseTable(seed, count) { const rng = mulberry32(seed); const table = new Float64Array(count); for (let i = 0; i < count; i++) table[i] = rng() * 2 - 1; return table; } /** * Smoothly interpolate the table at `t`, wrapping around its ends. `smoothstep` * makes the value continuous and its slope continuous at every cell boundary, so * the wobble has no visible kinks. */ function sampleTable(table, t) { const len = table.length; const base = Math.floor(t); const f = t - base; const i0 = ((base % len) + len) % len; const i1 = (i0 + 1) % len; const a = table[i0]; const b = table[i1]; return a + (b - a) * smoothstep(f); } /** * One-dimensional value noise, exposed mainly so tests can pin its behaviour. * The returned function is continuous, roughly in -1..1, deterministic for a * given seed, and returns 0 for non-finite input. * * @param {number} [seed=1] * @returns {(t: number) => number} */ export function makeNoise(seed = 1) { const table = buildNoiseTable(seed, NOISE_PERIOD); return function noise(t) { if (!Number.isFinite(t)) return 0; return sampleTable(table, t); }; } /** Euclidean distance between two points. */ function distance(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); } /** Cumulative arc length of every point, measured from the first. */ function arcPositions(points) { const pos = new Float64Array(points.length); for (let i = 1; i < points.length; i++) { pos[i] = pos[i - 1] + distance(points[i - 1], points[i]); } return pos; } /** True when a closed contour repeats its first point at the end. */ function hasClosingDuplicate(points) { const first = points[0]; const last = points[points.length - 1]; return Math.abs(first.x - last.x) <= EPSILON && Math.abs(first.y - last.y) <= EPSILON; } /** * Neighbour index for each point, honouring the wrap of a closed contour. Open * ends get -1, which makes the tangent fall back to a one-sided difference. */ function neighborIndices(count, closed, duplicate) { const prev = new Int32Array(count); const next = new Int32Array(count); if (!closed) { for (let i = 0; i < count; i++) { prev[i] = i > 0 ? i - 1 : -1; next[i] = i < count - 1 ? i + 1 : -1; } return { prev, next }; } // A repeated closing point is a copy of point 0, so the ring has one fewer // distinct vertex and the last index borrows point 0's two neighbours, which // is what makes its wobble identical to the first point's. const ring = duplicate ? count - 1 : count; const firstPrev = (ring - 1) % ring; const firstNext = ring > 1 ? 1 : 0; for (let i = 0; i < count; i++) { if (duplicate && i === count - 1) { prev[i] = firstPrev; next[i] = firstNext; } else { prev[i] = (i - 1 + ring) % ring; next[i] = (i + 1) % ring; } } return { prev, next }; } /** * Unit tangent at `i`, measured from the point before to the point after so the * wobble follows the stroke instead of the sampling. Returns null for a * degenerate point, where there is no direction to displace along. */ function tangentAt(points, i, prev, next) { const before = prev[i] >= 0 ? points[prev[i]] : points[i]; const after = next[i] >= 0 ? points[next[i]] : points[i]; const dx = after.x - before.x; const dy = after.y - before.y; const len = Math.hypot(dx, dy); if (!(len > EPSILON)) return null; return { x: dx / len, y: dy / len }; } /** * Fade the wobble to zero at both ends of an open stroke, over `ramp` units. * Without this the ends would fly off the traced geometry; a smooth ramp keeps * the stroke anchored while still looking freehand. */ function edgeWindow(s, length, ramp) { if (!(ramp > 0)) return 1; const head = Math.min(1, s / ramp); const tail = Math.min(1, (length - s) / ramp); return smoothstep(head) * smoothstep(tail); } /** Derive a distinct, deterministic seed for each extra pass. */ function mixSeed(seed, pass) { return (seed + pass * 0x9e3779b1) >>> 0; } /** Apply one wobble pass. `roughenPolyline` owns the input copy and the passes. */ function roughenOnce(points, { amount, seed, closed, scale }) { const count = points.length; const copy = () => points.map((p) => ({ x: p.x, y: p.y })); if (count < 2 || !(amount > 0) || !(scale > 0)) return copy(); const duplicate = closed ? hasClosingDuplicate(points) : false; const pos = arcPositions(points); const length = pos[count - 1]; let perimeter = length; if (closed) perimeter += distance(points[count - 1], points[0]); const { prev, next } = neighborIndices(count, closed, duplicate); // A closed stroke samples a circular table with a whole number of cells per // lap, so the last point lands on the first cell and the seam closes. An open // stroke samples plain (non-wrapping) noise and fades it out near the ends. let table; let cells = 0; if (closed) { if (!(perimeter > 0)) return copy(); cells = Math.max(2, Math.round(perimeter / scale)); table = buildNoiseTable(seed, cells); } else { table = buildNoiseTable(seed, NOISE_PERIOD); } const ramp = Math.min(scale, length * 0.25); const out = new Array(count); for (let i = 0; i < count; i++) { const tangent = tangentAt(points, i, prev, next); const p = points[i]; if (!tangent) { out[i] = { x: p.x, y: p.y }; continue; } const t = closed ? (pos[i] / perimeter) * cells : pos[i] / scale; let weight = sampleTable(table, t); if (!closed) weight *= edgeWindow(pos[i], length, ramp); const shift = amount * weight; if (shift === 0) { // Keep the original exactly (also avoids turning -0 into 0). out[i] = { x: p.x, y: p.y }; continue; } // Displace perpendicular to the tangent, i.e. along the local pen normal. out[i] = { x: p.x - tangent.y * shift, y: p.y + tangent.x * shift }; } if (closed && duplicate) { // Belt and braces: pin the explicit seam shut after any rounding. out[count - 1] = { x: out[0].x, y: out[0].y }; } return out; } /** * Displace every point of a polyline perpendicular to its local direction by * smooth noise. The input is never mutated and the point count never changes. * * Open polylines keep their first and last point exactly; closed ones wrap, so * the wobble is continuous across the seam. `amount` is the peak displacement in * the same units as the points, and `scale` is how much arc length one wobble * spans (larger = lazier, longer wobble). With `passes > 1` the displacement is * re-noised a few times at a share of `amount`, so the peak stays within * `amount` however many passes are used. * * @param {Array<{x: number, y: number}>} points * @param {object} [options] * @param {number} [options.amount=2] peak displacement, in point units * @param {number} [options.seed=1] deterministic seed * @param {boolean} [options.closed=false] treat the polyline as a ring * @param {number} [options.scale=40] arc length covered by one wobble * @param {number} [options.passes=1] number of noise layers * @returns {Array<{x: number, y: number}>} a new array of new points */ export function roughenPolyline(points, options = {}) { const list = Array.isArray(points) ? points : []; const amount = options.amount ?? 2; const seed = options.seed ?? 1; const closed = options.closed ?? false; const scale = options.scale ?? 40; const passes = Math.max(1, Math.floor(options.passes ?? 1)); let current = list.map((p) => ({ x: p.x, y: p.y })); if (current.length < 2 || amount === 0) return current; for (let pass = 0; pass < passes; pass++) { current = roughenOnce(current, { amount: amount / passes, seed: mixSeed(seed, pass), closed, scale, }); } return current; } /** * Roughen a list of contours, with the closed/open choice per contour. Following * `trace.js`'s convention, a contour is assumed to be a closed ring unless the * caller says otherwise: pass `options.closed` as a boolean for all of them or * as an array of flags indexed like `contours`. * * @param {Array>} contours * @param {object} [options] see `roughenPolyline`, plus `closed` as an array * @returns {Array>} */ export function roughenContours(contours, options = {}) { const list = Array.isArray(contours) ? contours : []; const closedOption = options.closed; const out = []; for (let i = 0; i < list.length; i++) { const closed = Array.isArray(closedOption) ? closedOption[i] ?? true : closedOption ?? true; out.push(roughenPolyline(list[i], { ...options, closed })); } return out; } /** * Roughen a list of contours and return their SVG `d` attribute, using exactly * the format of `contoursToPathData`: one `M … L … Z` subpath per contour, * coordinates rounded to `options.round` decimal places (default 2, the same * meaning as that function's `decimals` argument). * * @param {Array>} contours * @param {object} [options] roughening options, plus `round` and `mapPoint` * @param {number} [options.round=2] decimal places in the output * @param {(x: number, y: number) => [number, number]} [options.mapPoint] * @returns {string} */ export function handDrawnPathData(contours, options = {}) { const roughened = roughenContours(contours, options); const mapPoint = options.mapPoint ?? ((x, y) => [x, y]); return contoursToPathData(roughened, mapPoint, options.round ?? 2); } /** * Offset a stroke to both sides by a width that breathes slightly along its * length, giving the `[left, right]` polylines a pen stroke can be filled * between. Both sides keep the point count and order of `points`. * * The width only varies (it never reaches zero), so the two sides stay well * defined; `variation` is the fraction of `width` the wobble may add or remove. * * @param {Array<{x: number, y: number}>} points * @param {object} [options] * @param {number} [options.width=3] full stroke width * @param {number} [options.seed=1] * @param {boolean} [options.closed=false] * @param {number} [options.variation=0.35] relative width wobble * @param {number} [options.scale=40] arc length covered by one width wobble * @returns {[Array<{x: number, y: number}>, Array<{x: number, y: number}>]} */ export function taperStroke(points, options = {}) { const list = Array.isArray(points) ? points : []; const width = options.width ?? 3; const seed = options.seed ?? 1; const closed = options.closed ?? false; const variation = options.variation ?? 0.35; const scale = options.scale ?? 40; const count = list.length; const left = new Array(count); const right = new Array(count); if (count === 0) return [left, right]; if (count === 1) { left[0] = { x: list[0].x, y: list[0].y }; right[0] = { x: list[0].x, y: list[0].y }; return [left, right]; } const duplicate = closed ? hasClosingDuplicate(list) : false; const pos = arcPositions(list); const length = pos[count - 1]; let perimeter = length; if (closed) perimeter += distance(list[count - 1], list[0]); const { prev, next } = neighborIndices(count, closed, duplicate); let table; let cells = 0; if (closed && perimeter > 0 && scale > 0) { cells = Math.max(2, Math.round(perimeter / scale)); table = buildNoiseTable(seed, cells); } else { table = buildNoiseTable(seed, NOISE_PERIOD); } const span = scale > 0 ? scale : 1; const halfWidth = Math.abs(width) / 2; for (let i = 0; i < count; i++) { const tangent = tangentAt(list, i, prev, next); const p = list[i]; if (!tangent) { left[i] = { x: p.x, y: p.y }; right[i] = { x: p.x, y: p.y }; continue; } const t = closed && cells > 0 ? (pos[i] / perimeter) * cells : pos[i] / span; // Clamped well above zero so both sides keep a usable offset even when the // caller asks for a large `variation`. const factor = Math.max(0.05, 1 + variation * sampleTable(table, t)); const w = halfWidth * factor; left[i] = { x: p.x - tangent.y * w, y: p.y + tangent.x * w }; right[i] = { x: p.x + tangent.y * w, y: p.y - tangent.x * w }; } if (closed && duplicate) { left[count - 1] = { x: left[0].x, y: left[0].y }; right[count - 1] = { x: right[0].x, y: right[0].y }; } return [left, right]; }