diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-02 23:51:34 +0900 |
| commit | e332019acb312ec64893c26cf3d797d5ce472f26 (patch) | |
| tree | d97eaca75ad6d1d41658854d6b80519f6cd1ab79 /public/bluebey-studio/src/handDrawn.js | |
| parent | 99204ebe327657ed4aaaa92d7f2d6e0cb04a6b3b (diff) | |
bluebey: ぶるべー スタジオのページを公開
Diffstat (limited to 'public/bluebey-studio/src/handDrawn.js')
| -rw-r--r-- | public/bluebey-studio/src/handDrawn.js | 391 |
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]; +} |
