aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/handDrawn.js
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:09:40 +0900
commitbc2821adb11a30244fc4663f4fafb756877d9508 (patch)
treefea8d30d2eb1e67f895097dbf7ec4d152361243e /public/bluebey-studio/src/handDrawn.js
parent0d50c5ede0812ba7b67b775c9cd0bf52d7b65e02 (diff)
bluebey-studio: public/ の外へ移動し非公開化
Diffstat (limited to 'public/bluebey-studio/src/handDrawn.js')
-rw-r--r--public/bluebey-studio/src/handDrawn.js391
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];
-}