diff options
Diffstat (limited to 'public/bluebey-studio/src/handDrawn.js')
| -rw-r--r-- | public/bluebey-studio/src/handDrawn.js | 391 |
1 files changed, 0 insertions, 391 deletions
diff --git a/public/bluebey-studio/src/handDrawn.js b/public/bluebey-studio/src/handDrawn.js deleted file mode 100644 index e7fbce5..0000000 --- a/public/bluebey-studio/src/handDrawn.js +++ /dev/null @@ -1,391 +0,0 @@ -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<Array<{x: number, y: number}>>} contours - * @param {object} [options] see `roughenPolyline`, plus `closed` as an array - * @returns {Array<Array<{x: number, y: number}>>} - */ -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<Array<{x: number, y: number}>>} 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]; -} |
