Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313/* * The thread's geometry: a centreline through a few points, sampled evenly along its * length, and the ribbon drawn around it. The thread is drawn as a brush stroke — a * filled outline whose width wanders a little and whose edges are faintly ragged — so the * ribbon is a closed outline, not a stroke: its half-width on each side follows two seeded * noises, a slow one for the swell and a quick one for the fibres. It's revealed by a mask * that strokes the centreline (Ribbon.svelte), so it draws on like a pen. * * Pure: no DOM, so it runs in tests and on the server. */
export type Pt = readonly [x: number, y: number];
export type Cubic = readonly [Pt, Pt, Pt, Pt];
/** * Cubic segments through the points. Each handle runs along its point's tangent for a * third of its own segment, so a short segment beside a long one can't overshoot and kink * (plain Catmull-Rom does). `fixed` pins a point's tangent (a unit vector) instead, e.g. to * land square on a straight run. */function smooth(pts: readonly Pt[], fixed: readonly (Pt | undefined)[] = []): Cubic[] { const tangents = pts.map((_, i) => { if (fixed[i]) return fixed[i]; const prev = pts[i - 1] ?? pts[i]; const next = pts[i + 1] ?? pts[i]; const dx = next[0] - prev[0]; const dy = next[1] - prev[1]; const len = Math.hypot(dx, dy) || 1; return [dx / len, dy / len] as const; }); const out: Cubic[] = []; for (let i = 1; i < pts.length; i++) { const a = pts[i - 1]; const b = pts[i]; const h = Math.hypot(b[0] - a[0], b[1] - a[1]) / 3; const [t1, t2] = [tangents[i - 1], tangents[i]]; out.push([ a, [a[0] + t1[0] * h, a[1] + t1[1] * h], [b[0] - t2[0] * h, b[1] - t2[1] * h], b ]); } return out;}
function at([p0, p1, p2, p3]: Cubic, t: number): Pt { const u = 1 - t; const a = u * u * u; const b = 3 * u * u * t; const c = 3 * u * t * t; const d = t * t * t; return [ a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1] ];}
/** `n` points along a cubic, from its start to its end. */export function bezier(c: Cubic, n: number): Pt[] { const out: Pt[] = []; for (let i = 0; i <= n; i++) out.push(at(c, i / n)); return out;}
/** A line sampled every `step` px along its length, with the running length at each sample. */export interface Line { pts: Pt[]; /** Length from the start to each sample. */ len: number[]; total: number;}
/** Samples the curve through `pts` every `step` px of arc length; `fixed` as for `smooth`. */export function sample( pts: readonly Pt[], step = 2, fixed: readonly (Pt | undefined)[] = []): Line { if (pts.length < 2) return { pts: [...pts], len: pts.map(() => 0), total: 0 }; // Dense first, then walk it at an even step. const dense: Pt[] = [pts[0]]; for (const c of smooth(pts, fixed)) { const chord = Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]); const n = Math.max(4, Math.ceil(chord / 1.5)); for (let i = 1; i <= n; i++) dense.push(at(c, i / n)); } return trace(dense, step);}
/** Walks a dense polyline at an even `step` of arc length: a line as `sample` gives, for a * curve already drawn out point by point (an arc, a cubic). */export function trace(dense: readonly Pt[], step = 2): Line { if (dense.length < 2) return { pts: [...dense], len: dense.map(() => 0), total: 0 }; const out: Pt[] = [dense[0]]; const len = [0]; let walked = 0; let next = step; for (let i = 1; i < dense.length; i++) { const [ax, ay] = dense[i - 1]; const [bx, by] = dense[i]; const d = Math.hypot(bx - ax, by - ay); while (d > 0 && walked + d >= next) { const t = (next - walked) / d; out.push([ax + (bx - ax) * t, ay + (by - ay) * t]); len.push(next); next += step; } walked += d; } const last = dense[dense.length - 1]; if (walked - len[len.length - 1] > 0.01) { out.push(last); len.push(walked); } return { pts: out, len, total: walked };}
/** Joins sampled lines end to end, dropping each join's duplicate point. */export function join(...lines: Line[]): Line { const pts: Pt[] = []; const len: number[] = []; let offset = 0; for (const line of lines) { line.pts.forEach((p, i) => { if (i === 0 && pts.length) return; pts.push(p); len.push(offset + line.len[i]); }); offset += line.total; } return { pts, len, total: offset };}
/** A straight run, sampled like `sample`. */export function straight(a: Pt, b: Pt, step = 2): Line { const total = Math.hypot(b[0] - a[0], b[1] - a[1]); const n = Math.max(1, Math.ceil(total / step)); const pts: Pt[] = []; const len: number[] = []; for (let i = 0; i <= n; i++) { const t = i / n; pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); len.push(total * t); } return { pts, len, total };}
const fmt = (n: number) => Math.round(n * 100) / 100;
/** * Not a dash on the whole path: WebKit (iOS Safari) leaks stretches past the dash's end * through a mask on a line this long, which shows as the line broken further down. */export function polyline({ pts, len, total }: Line): (from: number, to: number) => string { const starts: number[] = []; let full = ""; pts.forEach(([x, y], i) => { starts.push(full.length); full += `${i ? "L" : "M"}${fmt(x)} ${fmt(y)}`; }); const cut = (at: number): [number, string] => { let lo = 1; let hi = pts.length - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (len[mid] < at) lo = mid + 1; else hi = mid; } const [ax, ay] = pts[lo - 1]; const [bx, by] = pts[lo]; const span = len[lo] - len[lo - 1]; const t = span > 0 ? (at - len[lo - 1]) / span : 1; return [lo, `${fmt(ax + (bx - ax) * t)} ${fmt(ay + (by - ay) * t)}`]; }; return (from, to) => { const a = Math.max(0, from); const b = Math.min(total, to); if (pts.length < 2 || b <= a) return ""; if (a === 0 && b === total) return full; const [i, p] = cut(a); const [j, q] = cut(b); return `M${p}${full.slice(starts[i], starts[j])}L${q}`; };}
/** * How far along the line the pen has to be to reach `y` on the page. The line can double * back on itself (the hero's curl), so this takes the first sample past `y` on its running * lowest point: scrolling down only ever draws more. */export function lengthAtY({ pts, len, total }: Line, y: number): number { if (!pts.length) return 0; if (y <= pts[0][1]) return 0; let low = -Infinity; for (let i = 0; i < pts.length; i++) { const py = pts[i][1]; if (py >= y && low < y) { const prev = Math.max(low, pts[i - 1]?.[1] ?? py); const span = py - prev; const t = span > 0 ? (y - prev) / span : 1; return (len[i - 1] ?? 0) + (len[i] - (len[i - 1] ?? 0)) * Math.max(0, Math.min(1, t)); } low = Math.max(low, py); } return total;}
/** Seeded 1D value noise in [0, 1], smoothly interpolated. */function noise(seed: number) { const hash = (i: number) => { let h = (i * 374761393 + seed * 668265263) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); return ((h ^ (h >>> 16)) >>> 0) / 4294967295; }; return (x: number) => { const i = Math.floor(x); const f = x - i; const s = f * f * (3 - 2 * f); return hash(i) * (1 - s) + hash(i + 1) * s; };}
export interface RibbonOptions { /** The brush's width (px). */ width?: number; /** How much the width swells and thins along the line (share of the width). */ swell?: number; /** How ragged each edge is (px). */ fray?: number; seed?: number; /** Taper over this many px at the start / end. */ taper?: readonly [start: number, end: number]; /** The brush's weight along the line, as a factor of its width at `s` px of `total`: * a stroke that runs thick into thin. */ profile?: (s: number, total: number) => number;}
/** * The brush stroke around a sampled line, as a closed SVG path: out along one edge and * back along the other. */export function ribbon( { pts, len, total }: Line, { width = 1.8, swell = 0.16, fray = 0.16, seed = 1, taper = [3, 3], profile }: RibbonOptions = {}): (from: number, to: number) => string { if (pts.length < 2) return () => ""; const slow = noise(seed); const quickL = noise(seed + 101); const quickR = noise(seed + 211); const left: string[] = []; const right: string[] = []; for (let i = 0; i < pts.length; i++) { const a = pts[Math.max(0, i - 1)]; const b = pts[Math.min(pts.length - 1, i + 1)]; const dx = b[0] - a[0]; const dy = b[1] - a[1]; const d = Math.hypot(dx, dy) || 1; const nx = -dy / d; const ny = dx / d; const s = len[i]; const ends = Math.min( taper[0] ? Math.min(1, s / taper[0]) : 1, taper[1] ? Math.min(1, (total - s) / taper[1]) : 1 ); const weight = profile ? profile(s, total) : 1; const half = (width / 2) * weight * (1 + swell * (slow(s / 46) * 2 - 1)) * (0.35 + 0.65 * ends); const l = half + fray * (quickL(s / 2.2) - 0.5); const r = half + fray * (quickR(s / 2.6) - 0.5); const [x, y] = pts[i]; left.push(`${fmt(x + nx * l)} ${fmt(y + ny * l)}`); right.push(`${fmt(x - nx * r)} ${fmt(y - ny * r)}`); } const index = (at: number) => { let lo = 0; let hi = pts.length - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (len[mid] < at) lo = mid + 1; else hi = mid; } return lo; }; return (from, to) => { const i = Math.max(0, index(from) - 1); const j = Math.min(pts.length - 1, index(to) + 1); const l = left.slice(i, j + 1); const r = right.slice(i, j + 1).reverse(); return `M${l.join("L")}L${r.join("L")}Z`; };}
/** The points' bounding box, grown by `pad`. */export function bounds(pts: readonly Pt[], pad = 0) { let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; for (const [x, y] of pts) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); } return { x: x0 - pad, y: y0 - pad, width: x1 - x0 + 2 * pad, height: y1 - y0 + 2 * pad };}