import { describe, expect, it } from "vitest"; import { bezier, join, lengthAtY, polyline, ribbon, sample, straight, trace } from "#lib/components/landing/thread/geometry.ts"; describe("sample", () => { it("walks a straight line at an even step", () => { const line = sample([ [0, 0], [0, 10] ]); expect(line.total).toBeCloseTo(10); expect(line.pts).toHaveLength(6); expect(line.len).toEqual([0, 2, 4, 6, 8, 10]); }); it("passes through every point it was given", () => { const line = sample([ [0, 0], [40, 30], [0, 80] ]); const near = (x: number, y: number) => line.pts.some(([px, py]) => Math.hypot(px - x, py - y) < 1.5); expect(near(40, 30)).toBe(true); expect(near(0, 80)).toBe(true); }); }); describe("trace", () => { it("walks a drawn-out curve at an even step", () => { const line = trace( bezier( [ [0, 0], [0, 10], [10, 10], [10, 20] ], 40 ), 2 ); expect(line.pts[0]).toEqual([0, 0]); expect(line.pts[line.pts.length - 1]).toEqual([10, 20]); for (let i = 1; i < line.len.length - 1; i++) expect(line.len[i] - line.len[i - 1]).toBeCloseTo(2); expect(line.total).toBeGreaterThan(Math.hypot(10, 20)); }); }); describe("bezier", () => { it("runs from the start to the end through the hull", () => { const pts = bezier( [ [0, 0], [0, 10], [10, 10], [10, 0] ], 4 ); expect(pts).toHaveLength(5); expect(pts[0]).toEqual([0, 0]); expect(pts[4]).toEqual([10, 0]); expect(pts[2][0]).toBeCloseTo(5); expect(pts[2][1]).toBeCloseTo(7.5); }); }); describe("join", () => { it("runs the lengths on and drops the shared point", () => { const a = straight([0, 0], [0, 4]); const b = straight([0, 4], [0, 10]); const line = join(a, b); expect(line.total).toBeCloseTo(10); expect(line.len.at(-1)).toBeCloseTo(10); expect(new Set(line.pts.map(([, y]) => y)).size).toBe(line.pts.length); }); }); describe("lengthAtY", () => { it("maps a height on a vertical run to its length", () => { const line = straight([0, 100], [0, 300]); expect(lengthAtY(line, 50)).toBe(0); expect(lengthAtY(line, 200)).toBeCloseTo(100); expect(lengthAtY(line, 400)).toBeCloseTo(200); }); it("only ever draws more as the page scrolls past a curl", () => { // down, back up, and down again const line = sample([ [0, 0], [0, 60], [30, 40], [60, 80], [60, 160] ]); let last = 0; for (let y = 0; y <= 170; y += 5) { const len = lengthAtY(line, y); expect(len).toBeGreaterThanOrEqual(last); last = len; } expect(last).toBeCloseTo(line.total); }); }); describe("ribbon", () => { const line = straight([0, 0], [0, 200]); it("is a closed outline, out along one edge and back along the other", () => { const d = ribbon(line)(0, line.total); expect(d.startsWith("M")).toBe(true); expect(d.endsWith("Z")).toBe(true); expect(d.split("L")).toHaveLength(line.pts.length * 2); }); it("stays within its width plus the swell and fray", () => { const xs = [ ...ribbon(line, { width: 2 })(0, line.total).matchAll(/(-?[\d.]+) -?[\d.]+/g) ].map((m) => Math.abs(Number(m[1]))); expect(Math.max(...xs)).toBeLessThan(1 * 1.16 + 0.08 + 1e-6); }); it("is the same brush for the same seed, and another for another", () => { expect(ribbon(line, { seed: 3 })(0, line.total)).toBe( ribbon(line, { seed: 3 })(0, line.total) ); expect(ribbon(line, { seed: 3 })(0, line.total)).not.toBe( ribbon(line, { seed: 4 })(0, line.total) ); }); it("slices the stretch between two lengths, a sample past each end", () => { const d = ribbon(line)(50, 100); expect(d.split("L")).toHaveLength((100 / 2 - 50 / 2 + 3) * 2); }); it("weighs the brush along the line by its profile", () => { const widths = (d: string) => { const xs = [...d.matchAll(/(-?[\d.]+) (-?[\d.]+)/g)].map((m) => [ Number(m[1]), Number(m[2]) ]); const n = xs.length / 2; // Out along one edge, back along the other: pair each sample with its twin. return xs.slice(0, n).map(([x], i) => Math.abs(x - xs[xs.length - 1 - i][0])); }; const plain = widths(ribbon(line, { swell: 0, fray: 0, taper: [0, 0] })(0, line.total)); const heavyToLight = widths( ribbon(line, { swell: 0, fray: 0, taper: [0, 0], profile: (s, total) => (s < total / 2 ? 2 : 0.5) })(0, line.total) ); const n = plain.length; expect(heavyToLight[2]).toBeCloseTo(plain[2] * 2); expect(heavyToLight[n - 3]).toBeCloseTo(plain[n - 3] * 0.5); }); }); describe("polyline", () => { const span = polyline(straight([0, 0], [0, 4])); it("draws the samples as one path", () => { expect(span(0, 4)).toBe("M0 0L0 2L0 4"); expect(span(-2, 10)).toBe("M0 0L0 2L0 4"); }); it("cuts off at the end, between samples", () => { expect(span(0, 3)).toBe("M0 0L0 2L0 3"); expect(span(0, 2)).toBe("M0 0L0 2"); expect(span(0, 0.5)).toBe("M0 0L0 0.5"); }); it("starts at the start, between samples", () => { expect(span(1, 3)).toBe("M0 1L0 2L0 3"); expect(span(2.5, 3.5)).toBe("M0 2.5L0 3.5"); }); it("draws nothing for an empty span", () => { expect(span(0, 0)).toBe(""); expect(span(3, 3)).toBe(""); }); });