blindspot / frontend /src /read /stroke.test.ts
yashwrites's picture
Signs on the film, draw tool, My search off by default, example films and Radiopaedia links (part 2)
2e6a666 verified
Raw History Blame Contribute Delete
3.18 kB
import { describe, expect, it } from 'vitest';
import { finishStroke, MAX_POINTS, polygonArea, polygonCentroid, rdp, shiftPolygon, simplifyStroke, strokeExtent, type Pt } from './stroke';
const circle = (cx: number, cy: number, r: number, n: number): Pt[] =>
Array.from({ length: n }, (_, i) => [cx + r * Math.cos((2 * Math.PI * i) / n), cy + r * Math.sin((2 * Math.PI * i) / n)]);
describe('stroke simplification', () => {
it('keeps a straight line as its two ends', () => {
const pts: Pt[] = Array.from({ length: 50 }, (_, i) => [i * 2, i * 2 + (i % 2) * 0.1]);
expect(rdp(pts, 0.5)).toEqual([pts[0], pts[49]]);
});
it('simplifies a dense trace to at most 120 points and keeps its shape', () => {
const dense = circle(300, 300, 120, 2000);
const out = simplifyStroke(dense);
expect(out.length).toBeLessThanOrEqual(MAX_POINTS);
expect(out.length).toBeGreaterThanOrEqual(12);
// Area within 3 % of the true circle, centroid within a pixel.
expect(Math.abs(Math.abs(polygonArea(out)) - Math.PI * 120 * 120) / (Math.PI * 120 * 120)).toBeLessThan(0.03);
const [cx, cy] = polygonCentroid(out);
expect(Math.abs(cx - 300)).toBeLessThan(1);
expect(Math.abs(cy - 300)).toBeLessThan(1);
});
it('drops duplicate points and a closing point equal to the first', () => {
expect(simplifyStroke([[0, 0], [0, 0], [10, 0], [10, 10], [10, 10], [0, 10], [0, 0]])).toEqual([[0, 0], [10, 0], [10, 10], [0, 10]]);
});
});
describe('polygon geometry', () => {
it('computes the area centroid of a square and a triangle', () => {
expect(polygonCentroid([[0, 0], [10, 0], [10, 10], [0, 10]])).toEqual([5, 5]);
const [tx, ty] = polygonCentroid([[0, 0], [30, 0], [0, 30]]);
expect(tx).toBeCloseTo(10);
expect(ty).toBeCloseTo(10);
});
it('falls back to the vertex mean for a degenerate polygon', () => {
expect(polygonCentroid([[0, 0], [10, 0], [20, 0]])).toEqual([10, 0]);
});
it('shifts every vertex', () => {
expect(shiftPolygon([[1, 1], [2, 2]], 3, -1)).toEqual([[4, 0], [5, 1]]);
});
it('measures the extent of a stroke', () => {
expect(strokeExtent([[2, 3], [5, 9]])).toBe(6);
expect(strokeExtent([])).toBe(0);
});
});
describe('finishStroke', () => {
it('turns a tiny stroke (< 8 screen px) into a point mark at its start', () => {
const r = finishStroke([[100, 100], [102, 101], [103, 103]], 1);
expect(r).toEqual({ kind: 'point', x: 100, y: 100 });
// The rule is in screen px: the same stroke zoomed 4× is an outline-sized gesture, but with no area it is still a point.
expect(finishStroke([[100, 100], [103, 100], [106, 100]], 4)?.kind).toBe('point');
});
it('closes a traced shape into a polygon with x, y at its centroid', () => {
const r = finishStroke(circle(200, 150, 40, 300), 1);
expect(r?.kind).toBe('outline');
if (r?.kind !== 'outline') return;
expect(r.polygon.length).toBeGreaterThanOrEqual(3);
expect(r.polygon.length).toBeLessThanOrEqual(MAX_POINTS);
expect(r.x).toBeCloseTo(200, 0);
expect(r.y).toBeCloseTo(150, 0);
});
it('returns null for no points', () => {
expect(finishStroke([], 1)).toBeNull();
});
});