feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import fc from "fast-check";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
IDENTITY,
|
||||
applyToPoint,
|
||||
composeTransformList,
|
||||
decomposeCanonical,
|
||||
diagnoseTransform,
|
||||
invert,
|
||||
matrixNearlyEqual,
|
||||
multiply,
|
||||
parseTransformList,
|
||||
recomposeCanonical,
|
||||
rotation,
|
||||
scaling,
|
||||
translation,
|
||||
} from "../../src/domain/affine";
|
||||
|
||||
describe("SVG affine transforms", () => {
|
||||
it("parses and composes source order according to SVG matrix semantics", () => {
|
||||
const matrix = composeTransformList(
|
||||
parseTransformList("translate(10 20) scale(2)"),
|
||||
);
|
||||
expect(matrix).toEqual({ a: 2, b: 0, c: 0, d: 2, e: 10, f: 20 });
|
||||
expect(applyToPoint(matrix, { x: 1, y: 1 })).toEqual({ x: 12, y: 22 });
|
||||
});
|
||||
|
||||
it("inverts and canonically decomposes a reflected shear", () => {
|
||||
const matrix = composeTransformList(
|
||||
parseTransformList("translate(8 -2) rotate(23) scale(3 -2) skewX(14)"),
|
||||
);
|
||||
expect(matrixNearlyEqual(multiply(matrix, invert(matrix)!), IDENTITY)).toBe(
|
||||
true,
|
||||
);
|
||||
const decomposition = decomposeCanonical(matrix);
|
||||
expect(decomposition.reflected).toBe(true);
|
||||
expect(matrixNearlyEqual(recomposeCanonical(decomposition), matrix)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(decomposition.residual).toBeLessThan(1e-10);
|
||||
});
|
||||
|
||||
it("rejects malformed arities and diagnoses singular matrices", () => {
|
||||
expect(() => parseTransformList("rotate(10 20)")).toThrow(
|
||||
/expects 1 or 3/u,
|
||||
);
|
||||
expect(diagnoseTransform("scale(1 0)").diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "singular-transform",
|
||||
severity: "error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips bounded points through generated nonsingular transform chains", () => {
|
||||
const coordinate = fc
|
||||
.integer({ min: -10_000, max: 10_000 })
|
||||
.map((value) => value / 10);
|
||||
const nonzeroScale = fc
|
||||
.integer({ min: -100, max: 100 })
|
||||
.filter((value) => value !== 0)
|
||||
.map((value) => value / 10);
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
coordinate,
|
||||
coordinate,
|
||||
fc.integer({ min: -720, max: 720 }),
|
||||
nonzeroScale,
|
||||
nonzeroScale,
|
||||
coordinate,
|
||||
coordinate,
|
||||
(tx, ty, angle, sx, sy, x, y) => {
|
||||
const matrix = multiply(
|
||||
translation(tx, ty),
|
||||
multiply(rotation(angle), scaling(sx, sy)),
|
||||
);
|
||||
const inverse = invert(matrix);
|
||||
if (!inverse) return false;
|
||||
const recovered = applyToPoint(
|
||||
inverse,
|
||||
applyToPoint(matrix, { x, y }),
|
||||
);
|
||||
return (
|
||||
Math.abs(recovered.x - x) < 1e-8 && Math.abs(recovered.y - y) < 1e-8
|
||||
);
|
||||
},
|
||||
),
|
||||
{ numRuns: 250 },
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user