93 lines
2.7 KiB
TypeScript
93 lines
2.7 KiB
TypeScript
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 },
|
|
);
|
|
});
|
|
});
|