aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/handDrawn.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/handDrawn.js')
-rw-r--r--public/bluebey-studio/src/handDrawn.js391
1 files changed, 391 insertions, 0 deletions
diff --git a/public/bluebey-studio/src/handDrawn.js b/public/bluebey-studio/src/handDrawn.js
new file mode 100644
index 0000000..e7fbce5
--- /dev/null
+++ b/public/bluebey-studio/src/handDrawn.js
@@ -0,0 +1,391 @@
+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];
+}