feat: introduce local-first SVG workbench
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
arcEndpointToCenter,
|
||||
describePathCommand,
|
||||
movePathHandle,
|
||||
parsePathData,
|
||||
pathHandles,
|
||||
pointOnArc,
|
||||
reversePath,
|
||||
serializePathData,
|
||||
splitSegment,
|
||||
transformPath,
|
||||
} from "../../src/domain/path";
|
||||
|
||||
describe("application-owned SVG path model", () => {
|
||||
it("normalizes every command family, relatives, repeats and shorthand controls", () => {
|
||||
const model = parsePathData(
|
||||
"m 10 10 5 5 h 10 v 10 c 1 2 3 4 5 6 s 7 8 9 10 q 2 3 4 5 t 6 7 a 8 9 30 0 1 10 11 z",
|
||||
);
|
||||
expect(model.segments.map((segment) => segment.kind)).toEqual([
|
||||
"M",
|
||||
"L",
|
||||
"L",
|
||||
"L",
|
||||
"C",
|
||||
"C",
|
||||
"Q",
|
||||
"Q",
|
||||
"A",
|
||||
"Z",
|
||||
]);
|
||||
const firstCubic = model.segments[4];
|
||||
const smoothCubic = model.segments[5];
|
||||
expect(firstCubic?.kind).toBe("C");
|
||||
expect(smoothCubic?.kind).toBe("C");
|
||||
if (firstCubic?.kind === "C" && smoothCubic?.kind === "C") {
|
||||
expect(smoothCubic.control1).toEqual({
|
||||
x: 2 * smoothCubic.from.x - firstCubic.control2.x,
|
||||
y: 2 * smoothCubic.from.y - firstCubic.control2.y,
|
||||
});
|
||||
expect(smoothCubic).toMatchObject({
|
||||
derivedControl1: true,
|
||||
sourceForm: { command: "s", relative: true },
|
||||
});
|
||||
const derived = pathHandles(model).find(
|
||||
(handle) => handle.segmentIndex === 5 && handle.role === "control-1",
|
||||
)!;
|
||||
expect(derived.derived).toBe(true);
|
||||
const explicit = movePathHandle(model, derived, {
|
||||
x: derived.point.x + 1,
|
||||
y: derived.point.y,
|
||||
});
|
||||
expect(explicit.segments[5]).toMatchObject({ derivedControl1: false });
|
||||
}
|
||||
expect(model.segments[7]).toMatchObject({
|
||||
derivedControl: true,
|
||||
sourceForm: { command: "t" },
|
||||
});
|
||||
expect(
|
||||
model.segments.every(
|
||||
({ sourceForm }) =>
|
||||
sourceForm !== undefined &&
|
||||
sourceForm.sourceRange.to > sourceForm.sourceRange.from,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("supports compact numbers, exponent notation and packed arc flags", () => {
|
||||
const model = parsePathData("M.5.6L10-5e-1A5 6 0 0110 20");
|
||||
expect(model.segments).toHaveLength(3);
|
||||
expect(model.segments[0]).toMatchObject({ to: { x: 0.5, y: 0.6 } });
|
||||
expect(model.segments[1]).toMatchObject({ to: { x: 10, y: -0.5 } });
|
||||
expect(model.segments[2]).toMatchObject({
|
||||
kind: "A",
|
||||
largeArc: false,
|
||||
sweep: true,
|
||||
to: { x: 10, y: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips normalized geometry and rejects malformed data", () => {
|
||||
const model = parsePathData(
|
||||
"M0 0 C1 2 3 4 5 6 Q7 8 9 10 A4 3 20 1 0 12 13 Z",
|
||||
);
|
||||
expect(serializePathData(parsePathData(serializePathData(model)))).toBe(
|
||||
serializePathData(model),
|
||||
);
|
||||
expect(() => parsePathData("L 1 2")).toThrow(/begin with a moveto/u);
|
||||
expect(() => parsePathData("M 0 0 A 5 5 0 2 0 10 10")).toThrow(
|
||||
/flag must be 0 or 1/u,
|
||||
);
|
||||
expect(() => parsePathData("M 0,")).toThrow(/comma/iu);
|
||||
expect(() => parsePathData("M0 0 1 1 2 2", 2)).toThrow(/segment limit/u);
|
||||
});
|
||||
|
||||
it("splits cubic geometry with de Casteljau and reverses arc sweep", () => {
|
||||
const cubic = parsePathData("M0 0 C10 0 10 10 20 10");
|
||||
const split = splitSegment(cubic, 1, 0.5);
|
||||
expect(split.segments).toHaveLength(3);
|
||||
expect(split.segments[1]).toMatchObject({ kind: "C", to: { x: 10, y: 5 } });
|
||||
expect(split.segments[2]).toMatchObject({
|
||||
kind: "C",
|
||||
from: { x: 10, y: 5 },
|
||||
});
|
||||
const arc = parsePathData("M0 0 A10 5 20 0 1 20 0");
|
||||
const reversed = reversePath(arc);
|
||||
expect(reversed.segments[1]).toMatchObject({ kind: "A", sweep: false });
|
||||
expect(serializePathData(reversePath(reversed))).toBe(
|
||||
serializePathData(arc),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives arc center/radius controls and moves path handles", () => {
|
||||
const model = parsePathData("M0 0 A4 3 30 0 1 12 0");
|
||||
const segment = model.segments[1];
|
||||
expect(segment?.kind).toBe("A");
|
||||
if (segment?.kind !== "A") return;
|
||||
const center = arcEndpointToCenter(segment)!;
|
||||
expect(pointOnArc(center, center.startAngle).x).toBeCloseTo(segment.from.x);
|
||||
const anchor = pathHandles(model).find(
|
||||
(handle) => handle.role === "anchor" && handle.segmentIndex === 1,
|
||||
)!;
|
||||
expect(
|
||||
movePathHandle(model, anchor, { x: 14, y: 2 }).segments[1],
|
||||
).toMatchObject({ to: { x: 14, y: 2 } });
|
||||
});
|
||||
|
||||
it("bakes nonsingular affine matrices and flips arc sweep under reflection", () => {
|
||||
const model = parsePathData("M0 0 A10 5 20 0 1 20 0");
|
||||
const transformed = transformPath(model, {
|
||||
a: -2,
|
||||
b: 0.5,
|
||||
c: 0.25,
|
||||
d: 3,
|
||||
e: 4,
|
||||
f: 8,
|
||||
});
|
||||
expect(transformed.segments[1]).toMatchObject({ kind: "A", sweep: false });
|
||||
expect(() =>
|
||||
transformPath(model, { a: 1, b: 0, c: 0, d: 0, e: 0, f: 0 }),
|
||||
).toThrow(/singular/u);
|
||||
});
|
||||
it("describes source form, endpoints, controls, derived shorthand and arc flags", () => {
|
||||
const source = "m 1 2 3 4 s 5 6 7 8 t 9 10 a 11 12 30 1 0 13 14";
|
||||
const segments = parsePathData(source).segments;
|
||||
|
||||
expect(describePathCommand(segments[1]!, source)).toMatchObject({
|
||||
sourceCommand: "m",
|
||||
normalizedCommand: "L",
|
||||
sourceFragment: "3 4",
|
||||
form: "relative · implicit repeat",
|
||||
endpoint: "4, 6",
|
||||
});
|
||||
expect(describePathCommand(segments[2]!, source).details).toEqual([
|
||||
{ label: "C1", value: "4, 6", derived: true },
|
||||
{ label: "C2", value: "9, 12", derived: false },
|
||||
]);
|
||||
expect(describePathCommand(segments[3]!, source).details[0]).toMatchObject({
|
||||
label: "C",
|
||||
derived: true,
|
||||
});
|
||||
expect(describePathCommand(segments[4]!, source).details).toEqual([
|
||||
{ label: "Radii", value: "11 × 12", derived: false },
|
||||
{ label: "Rotation", value: "30°", derived: false },
|
||||
{ label: "Flags", value: "large 1 · sweep 0", derived: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user