1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
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];
}
|