Files
svg-tools/src/domain/path.ts
T

1060 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defaultSvgLimits } from "../app/limits";
import { applyToPoint, determinant, type Matrix, type Point } from "./affine";
export type PathCommand =
| "M"
| "m"
| "L"
| "l"
| "H"
| "h"
| "V"
| "v"
| "C"
| "c"
| "S"
| "s"
| "Q"
| "q"
| "T"
| "t"
| "A"
| "a"
| "Z"
| "z";
export interface PathSourceForm {
command: PathCommand;
relative: boolean;
repeatedParameterGroup: boolean;
sourceRange: { from: number; to: number };
}
interface SourceRepresentedSegment {
sourceForm?: PathSourceForm;
}
export interface MoveSegment extends SourceRepresentedSegment {
kind: "M";
to: Point;
}
export interface LineSegment extends SourceRepresentedSegment {
kind: "L";
from: Point;
to: Point;
}
export interface CubicSegment extends SourceRepresentedSegment {
kind: "C";
from: Point;
control1: Point;
control2: Point;
to: Point;
derivedControl1?: boolean;
}
export interface QuadraticSegment extends SourceRepresentedSegment {
kind: "Q";
from: Point;
control: Point;
to: Point;
derivedControl?: boolean;
}
export interface ArcSegment extends SourceRepresentedSegment {
kind: "A";
from: Point;
to: Point;
rx: number;
ry: number;
rotation: number;
largeArc: boolean;
sweep: boolean;
}
export interface CloseSegment extends SourceRepresentedSegment {
kind: "Z";
from: Point;
to: Point;
}
export type GeometrySegment =
| LineSegment
| CubicSegment
| QuadraticSegment
| ArcSegment;
export type PathSegment = MoveSegment | GeometrySegment | CloseSegment;
export interface PathModel {
segments: PathSegment[];
}
type Command = PathCommand;
interface CommandToken {
type: "command";
value: Command;
offset: number;
}
interface NumberToken {
type: "number";
value: number;
raw: string;
offset: number;
}
type Token = CommandToken | NumberToken;
const COMMANDS = "MmLlHhVvCcSsQqTtAaZz";
const NUMBER_SOURCE =
"[-+]?(?:(?:\\d+\\.\\d*)|(?:\\.\\d+)|(?:\\d+))(?:[eE][-+]?\\d+)?";
const COMPLETE_NUMBER = new RegExp(`^(?:${NUMBER_SOURCE})$`, "u");
const isCommand = (value: string): value is Command =>
value.length === 1 && COMMANDS.includes(value);
function tokenize(source: string): Token[] {
const result: Token[] = [];
const numberPattern = new RegExp(NUMBER_SOURCE, "uy");
let offset = 0;
while (offset < source.length) {
const character = source[offset]!;
if (/\s/u.test(character)) {
offset += 1;
continue;
}
if (character === ",") {
if (result.at(-1)?.type !== "number") {
throw new SyntaxError(`Unexpected comma at offset ${offset}`);
}
offset += 1;
while (/\s/u.test(source[offset] ?? "")) offset += 1;
if (
offset >= source.length ||
source[offset] === "," ||
isCommand(source[offset]!)
) {
throw new SyntaxError(
`Comma at offset ${offset - 1} has no value after it`,
);
}
continue;
}
if (isCommand(character)) {
result.push({ type: "command", value: character, offset });
offset += 1;
continue;
}
numberPattern.lastIndex = offset;
const match = numberPattern.exec(source);
if (!match) {
throw new SyntaxError(
`Unexpected character ${JSON.stringify(character)} at offset ${offset}`,
);
}
const value = Number(match[0]);
if (!Number.isFinite(value)) {
throw new SyntaxError(`Non-finite number at offset ${offset}`);
}
result.push({ type: "number", value, raw: match[0], offset });
offset = numberPattern.lastIndex;
}
return result;
}
const point = (x: number, y: number): Point => ({ x, y });
const clonePoint = (value: Point): Point => ({ ...value });
const reflect = (control: Point, around: Point): Point => ({
x: 2 * around.x - control.x,
y: 2 * around.y - control.y,
});
export function parsePathData(
source: string,
maximumSegments = defaultSvgLimits.maximumPathCommandsPerPath,
): PathModel {
const tokens = tokenize(source);
if (tokens.length === 0) return { segments: [] };
if (tokens.length > defaultSvgLimits.maximumPathCommandsPerPath * 8) {
throw new RangeError("Path exceeds the configured token limit");
}
const segments: PathSegment[] = [];
let tokenIndex = 0;
let current = point(0, 0);
let subpathStart: Point | null = null;
let previous: PathSegment | undefined;
let hasMove = false;
const number = (label: string): NumberToken => {
const token = tokens[tokenIndex];
if (!token || token.type !== "number") {
throw new SyntaxError(
`Expected ${label} at offset ${token?.offset ?? source.length}`,
);
}
tokenIndex += 1;
return token;
};
const flag = (label: string): boolean => {
const token = tokens[tokenIndex];
if (!token || token.type !== "number") {
throw new SyntaxError(
`Expected ${label} flag at offset ${token?.offset ?? source.length}`,
);
}
const first = token.raw[0];
if (first !== "0" && first !== "1") {
throw new SyntaxError(
`${label} flag must be 0 or 1 at offset ${token.offset}`,
);
}
const remainder = token.raw.slice(1);
if (remainder === "") tokenIndex += 1;
else {
if (!COMPLETE_NUMBER.test(remainder)) {
throw new SyntaxError(
`Invalid compact arc flags at offset ${token.offset}`,
);
}
token.raw = remainder;
token.value = Number(remainder);
token.offset += 1;
}
return first === "1";
};
const append = (segment: PathSegment) => {
if (segments.length >= maximumSegments) {
throw new RangeError("Path exceeds the configured segment limit");
}
segments.push(segment);
previous = segment;
};
while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
if (!token || token.type !== "command") {
throw new SyntaxError(
`Expected a path command at offset ${token?.offset ?? source.length}`,
);
}
tokenIndex += 1;
const command = token.value;
const upper = command.toUpperCase();
const relative = command !== upper;
if (upper === "Z") {
if (!subpathStart) {
throw new SyntaxError(`Close before moveto at offset ${token.offset}`);
}
append({
kind: "Z",
from: clonePoint(current),
to: clonePoint(subpathStart),
sourceForm: {
command,
relative,
repeatedParameterGroup: false,
sourceRange: { from: token.offset, to: token.offset + 1 },
},
});
current = clonePoint(subpathStart);
continue;
}
if (!hasMove && upper !== "M") {
throw new SyntaxError("Path data must begin with a moveto command");
}
let group = 0;
while (tokens[tokenIndex]?.type === "number") {
const groupFrom = tokens[tokenIndex]!.offset;
const from = clonePoint(current);
const absolute = (x: number, y: number): Point =>
relative ? point(from.x + x, from.y + y) : point(x, y);
switch (upper) {
case "M": {
const to = absolute(
number("moveto x").value,
number("moveto y").value,
);
if (group === 0) {
append({ kind: "M", to });
subpathStart = clonePoint(to);
hasMove = true;
} else append({ kind: "L", from, to });
current = clonePoint(to);
break;
}
case "L": {
const to = absolute(
number("lineto x").value,
number("lineto y").value,
);
append({ kind: "L", from, to });
current = clonePoint(to);
break;
}
case "H": {
const value = number("horizontal coordinate").value;
const to = point(relative ? from.x + value : value, from.y);
append({ kind: "L", from, to });
current = clonePoint(to);
break;
}
case "V": {
const value = number("vertical coordinate").value;
const to = point(from.x, relative ? from.y + value : value);
append({ kind: "L", from, to });
current = clonePoint(to);
break;
}
case "C": {
const control1 = absolute(
number("cubic control 1 x").value,
number("cubic control 1 y").value,
);
const control2 = absolute(
number("cubic control 2 x").value,
number("cubic control 2 y").value,
);
const to = absolute(
number("cubic endpoint x").value,
number("cubic endpoint y").value,
);
append({ kind: "C", from, control1, control2, to });
current = clonePoint(to);
break;
}
case "S": {
const control1 =
previous?.kind === "C"
? reflect(previous.control2, from)
: clonePoint(from);
const control2 = absolute(
number("smooth cubic control x").value,
number("smooth cubic control y").value,
);
const to = absolute(
number("smooth cubic endpoint x").value,
number("smooth cubic endpoint y").value,
);
append({
kind: "C",
from,
control1,
control2,
to,
derivedControl1: true,
});
current = clonePoint(to);
break;
}
case "Q": {
const control = absolute(
number("quadratic control x").value,
number("quadratic control y").value,
);
const to = absolute(
number("quadratic endpoint x").value,
number("quadratic endpoint y").value,
);
append({ kind: "Q", from, control, to });
current = clonePoint(to);
break;
}
case "T": {
const control =
previous?.kind === "Q"
? reflect(previous.control, from)
: clonePoint(from);
const to = absolute(
number("smooth quadratic endpoint x").value,
number("smooth quadratic endpoint y").value,
);
append({ kind: "Q", from, control, to, derivedControl: true });
current = clonePoint(to);
break;
}
case "A": {
const rx = Math.abs(number("arc rx").value);
const ry = Math.abs(number("arc ry").value);
const rotation = number("arc rotation").value;
const largeArc = flag("large-arc");
const sweep = flag("sweep");
const to = absolute(
number("arc endpoint x").value,
number("arc endpoint y").value,
);
append({
kind: "A",
from,
to,
rx,
ry,
rotation,
largeArc,
sweep,
});
current = clonePoint(to);
break;
}
default:
throw new SyntaxError(`Unsupported path command ${command}`);
}
const appended = segments.at(-1);
if (appended) {
appended.sourceForm = {
command,
relative,
repeatedParameterGroup: group > 0,
sourceRange: {
from: group === 0 ? token.offset : groupFrom,
to: tokens[tokenIndex]?.offset ?? source.length,
},
};
}
group += 1;
if (segments.length > defaultSvgLimits.maximumPathCommandsPerPath) {
throw new RangeError("Path exceeds the configured command limit");
}
}
if (group === 0) {
throw new SyntaxError(
`Command ${command} has no parameters at offset ${token.offset}`,
);
}
}
return { segments };
}
function cleanNumber(value: number, precision: number): string {
if (!Number.isFinite(value)) {
throw new TypeError("Path coordinates must be finite");
}
const rounded = Number(value.toFixed(precision));
return Object.is(rounded, -0) ? "0" : String(rounded);
}
export function serializePathData(model: PathModel, precision = 6): string {
const digits = Math.max(0, Math.min(15, Math.trunc(precision)));
const n = (value: number) => cleanNumber(value, digits);
const p = (value: Point) => `${n(value.x)} ${n(value.y)}`;
return model.segments
.map((segment) => {
switch (segment.kind) {
case "M":
return `M ${p(segment.to)}`;
case "L":
return `L ${p(segment.to)}`;
case "C":
return `C ${p(segment.control1)} ${p(segment.control2)} ${p(segment.to)}`;
case "Q":
return `Q ${p(segment.control)} ${p(segment.to)}`;
case "A":
return `A ${n(segment.rx)} ${n(segment.ry)} ${n(segment.rotation)} ${segment.largeArc ? 1 : 0} ${segment.sweep ? 1 : 0} ${p(segment.to)}`;
case "Z":
return "Z";
}
})
.join(" ");
}
export interface PathCommandRow {
sourceCommand: string;
normalizedCommand: PathSegment["kind"];
sourceFragment: string;
form: string;
endpoint: string;
details: Array<{ label: string; value: string; derived: boolean }>;
}
function pointText(value: Point): string {
return `${value.x}, ${value.y}`;
}
export function describePathCommand(
segment: PathSegment,
source: string,
): PathCommandRow {
const sourceForm = segment.sourceForm;
const sourceFragment = sourceForm
? source
.slice(sourceForm.sourceRange.from, sourceForm.sourceRange.to)
.trim()
: serializePathData({ segments: [segment] });
const details: PathCommandRow["details"] = [];
if (segment.kind === "C") {
details.push({
label: "C1",
value: pointText(segment.control1),
derived: segment.derivedControl1 === true,
});
details.push({
label: "C2",
value: pointText(segment.control2),
derived: false,
});
} else if (segment.kind === "Q") {
details.push({
label: "C",
value: pointText(segment.control),
derived: segment.derivedControl === true,
});
} else if (segment.kind === "A") {
details.push({
label: "Radii",
value: `${segment.rx} × ${segment.ry}`,
derived: false,
});
details.push({
label: "Rotation",
value: `${segment.rotation}°`,
derived: false,
});
details.push({
label: "Flags",
value: `large ${Number(segment.largeArc)} · sweep ${Number(segment.sweep)}`,
derived: false,
});
}
return {
sourceCommand: sourceForm?.command ?? segment.kind,
normalizedCommand: segment.kind,
sourceFragment,
form: sourceForm
? `${sourceForm.relative ? "relative" : "absolute"}${sourceForm.repeatedParameterGroup ? " · implicit repeat" : ""}`
: "normalized",
endpoint: pointText(segment.to),
details,
};
}
export interface ArcCenterParameters {
center: Point;
rx: number;
ry: number;
rotationRadians: number;
startAngle: number;
deltaAngle: number;
}
function vectorAngle(ux: number, uy: number, vx: number, vy: number): number {
return Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
}
export function arcEndpointToCenter(
arc: ArcSegment,
): ArcCenterParameters | null {
if (
arc.rx === 0 ||
arc.ry === 0 ||
(arc.from.x === arc.to.x && arc.from.y === arc.to.y)
) {
return null;
}
let rx = Math.abs(arc.rx);
let ry = Math.abs(arc.ry);
const phi = ((arc.rotation % 360) * Math.PI) / 180;
const cosine = Math.cos(phi);
const sine = Math.sin(phi);
const halfX = (arc.from.x - arc.to.x) / 2;
const halfY = (arc.from.y - arc.to.y) / 2;
const xPrime = cosine * halfX + sine * halfY;
const yPrime = -sine * halfX + cosine * halfY;
const lambda = (xPrime * xPrime) / (rx * rx) + (yPrime * yPrime) / (ry * ry);
if (lambda > 1) {
const scale = Math.sqrt(lambda);
rx *= scale;
ry *= scale;
}
const rx2 = rx * rx;
const ry2 = ry * ry;
const x2 = xPrime * xPrime;
const y2 = yPrime * yPrime;
const denominator = rx2 * y2 + ry2 * x2;
if (denominator === 0) return null;
const coefficient =
(arc.largeArc === arc.sweep ? -1 : 1) *
Math.sqrt(Math.max(0, (rx2 * ry2 - rx2 * y2 - ry2 * x2) / denominator));
const centerPrimeX = coefficient * ((rx * yPrime) / ry);
const centerPrimeY = coefficient * (-(ry * xPrime) / rx);
const center = point(
cosine * centerPrimeX - sine * centerPrimeY + (arc.from.x + arc.to.x) / 2,
sine * centerPrimeX + cosine * centerPrimeY + (arc.from.y + arc.to.y) / 2,
);
const startX = (xPrime - centerPrimeX) / rx;
const startY = (yPrime - centerPrimeY) / ry;
const endX = (-xPrime - centerPrimeX) / rx;
const endY = (-yPrime - centerPrimeY) / ry;
const startAngle = vectorAngle(1, 0, startX, startY);
let deltaAngle = vectorAngle(startX, startY, endX, endY);
if (!arc.sweep && deltaAngle > 0) deltaAngle -= Math.PI * 2;
else if (arc.sweep && deltaAngle < 0) deltaAngle += Math.PI * 2;
return {
center,
rx,
ry,
rotationRadians: phi,
startAngle,
deltaAngle,
};
}
export function pointOnArc(
parameters: ArcCenterParameters,
angle: number,
): Point {
const rotationCosine = Math.cos(parameters.rotationRadians);
const rotationSine = Math.sin(parameters.rotationRadians);
const angleCosine = Math.cos(angle);
const angleSine = Math.sin(angle);
return point(
parameters.center.x +
rotationCosine * parameters.rx * angleCosine -
rotationSine * parameters.ry * angleSine,
parameters.center.y +
rotationSine * parameters.rx * angleCosine +
rotationCosine * parameters.ry * angleSine,
);
}
export interface PathHandle {
id: string;
segmentIndex: number;
role:
| "anchor"
| "control-1"
| "control-2"
| "control"
| "arc-center"
| "arc-radius-x"
| "arc-radius-y";
point: Point;
derived?: boolean;
}
export function pathHandles(model: PathModel): PathHandle[] {
const handles: PathHandle[] = [];
model.segments.forEach((segment, segmentIndex) => {
if (segment.kind === "M") {
handles.push({
id: `${segmentIndex}:anchor`,
segmentIndex,
role: "anchor",
point: clonePoint(segment.to),
});
return;
}
if (segment.kind === "Z") return;
handles.push({
id: `${segmentIndex}:anchor`,
segmentIndex,
role: "anchor",
point: clonePoint(segment.to),
});
if (segment.kind === "C") {
handles.push({
id: `${segmentIndex}:control-1`,
segmentIndex,
role: "control-1",
point: clonePoint(segment.control1),
derived: segment.derivedControl1,
});
handles.push({
id: `${segmentIndex}:control-2`,
segmentIndex,
role: "control-2",
point: clonePoint(segment.control2),
});
} else if (segment.kind === "Q") {
handles.push({
id: `${segmentIndex}:control`,
segmentIndex,
role: "control",
point: clonePoint(segment.control),
derived: segment.derivedControl,
});
} else if (segment.kind === "A") {
const arc = arcEndpointToCenter(segment);
if (arc) {
handles.push({
id: `${segmentIndex}:arc-center`,
segmentIndex,
role: "arc-center",
point: clonePoint(arc.center),
});
handles.push({
id: `${segmentIndex}:arc-radius-x`,
segmentIndex,
role: "arc-radius-x",
point: pointOnArc(arc, 0),
});
handles.push({
id: `${segmentIndex}:arc-radius-y`,
segmentIndex,
role: "arc-radius-y",
point: pointOnArc(arc, Math.PI / 2),
});
}
}
});
return handles;
}
export function movePathHandle(
model: PathModel,
handle: PathHandle,
to: Point,
): PathModel {
const segments = model.segments.map((segment) => structuredClone(segment));
const segment = segments[handle.segmentIndex];
if (!segment) throw new RangeError("Path handle segment is stale");
switch (handle.role) {
case "anchor": {
const old = clonePoint(segment.to);
const delta = point(to.x - old.x, to.y - old.y);
segment.to = clonePoint(to);
if (segment.kind === "M") {
const next = segments[handle.segmentIndex + 1];
if (next && next.kind !== "M") next.from = clonePoint(to);
} else if (segment.kind !== "Z") {
if (segment.kind === "C") {
segment.control2 = point(
segment.control2.x + delta.x,
segment.control2.y + delta.y,
);
}
const next = segments[handle.segmentIndex + 1];
if (next && next.kind !== "M") {
next.from = clonePoint(to);
if (next.kind === "C") {
next.control1 = point(
next.control1.x + delta.x,
next.control1.y + delta.y,
);
}
if (next.kind === "Q") {
next.control = point(
next.control.x + delta.x,
next.control.y + delta.y,
);
}
}
}
break;
}
case "control-1":
if (segment.kind !== "C") {
throw new TypeError("Control handle does not match a cubic segment");
}
segment.control1 = clonePoint(to);
segment.derivedControl1 = false;
break;
case "control-2":
if (segment.kind !== "C") {
throw new TypeError("Control handle does not match a cubic segment");
}
segment.control2 = clonePoint(to);
break;
case "control":
if (segment.kind !== "Q") {
throw new TypeError(
"Control handle does not match a quadratic segment",
);
}
segment.control = clonePoint(to);
segment.derivedControl = false;
break;
case "arc-center":
throw new Error(
"Arc centers are derived; move endpoints or radius handles instead",
);
case "arc-radius-x":
case "arc-radius-y": {
if (segment.kind !== "A") {
throw new TypeError("Arc handle does not match an arc segment");
}
const arc = arcEndpointToCenter(segment);
if (!arc) throw new Error("Degenerate arc has no radius handles");
const dx = to.x - arc.center.x;
const dy = to.y - arc.center.y;
const cosine = Math.cos(-arc.rotationRadians);
const sine = Math.sin(-arc.rotationRadians);
const localX = cosine * dx - sine * dy;
const localY = sine * dx + cosine * dy;
if (handle.role === "arc-radius-x") {
segment.rx = Math.max(0.001, Math.abs(localX));
} else segment.ry = Math.max(0.001, Math.abs(localY));
break;
}
}
return { segments };
}
const interpolate = (left: Point, right: Point, amount: number): Point => ({
x: left.x + (right.x - left.x) * amount,
y: left.y + (right.y - left.y) * amount,
});
export function splitSegment(
model: PathModel,
segmentIndex: number,
amount = 0.5,
): PathModel {
if (!Number.isFinite(amount) || amount <= 0 || amount >= 1) {
throw new RangeError("Split amount must be between zero and one");
}
const segment = model.segments[segmentIndex];
if (!segment || segment.kind === "M" || segment.kind === "Z") {
throw new TypeError("Only drawable segments can be split");
}
let replacements: GeometrySegment[];
if (segment.kind === "C") {
const p01 = interpolate(segment.from, segment.control1, amount);
const p12 = interpolate(segment.control1, segment.control2, amount);
const p23 = interpolate(segment.control2, segment.to, amount);
const p012 = interpolate(p01, p12, amount);
const p123 = interpolate(p12, p23, amount);
const split = interpolate(p012, p123, amount);
replacements = [
{
kind: "C",
from: clonePoint(segment.from),
control1: p01,
control2: p012,
to: split,
},
{
kind: "C",
from: clonePoint(split),
control1: p123,
control2: p23,
to: clonePoint(segment.to),
},
];
} else if (segment.kind === "Q") {
const p01 = interpolate(segment.from, segment.control, amount);
const p12 = interpolate(segment.control, segment.to, amount);
const split = interpolate(p01, p12, amount);
replacements = [
{
kind: "Q",
from: clonePoint(segment.from),
control: p01,
to: split,
},
{
kind: "Q",
from: clonePoint(split),
control: p12,
to: clonePoint(segment.to),
},
];
} else if (segment.kind === "L") {
const split = interpolate(segment.from, segment.to, amount);
replacements = [
{ kind: "L", from: clonePoint(segment.from), to: split },
{ kind: "L", from: clonePoint(split), to: clonePoint(segment.to) },
];
} else {
const arc = arcEndpointToCenter(segment);
if (!arc) {
const split = interpolate(segment.from, segment.to, amount);
replacements = [
{ kind: "L", from: clonePoint(segment.from), to: split },
{ kind: "L", from: clonePoint(split), to: clonePoint(segment.to) },
];
} else {
const split = pointOnArc(arc, arc.startAngle + arc.deltaAngle * amount);
replacements = [
{
...segment,
from: clonePoint(segment.from),
to: split,
largeArc: Math.abs(arc.deltaAngle * amount) > Math.PI,
},
{
...segment,
from: clonePoint(split),
to: clonePoint(segment.to),
largeArc: Math.abs(arc.deltaAngle * (1 - amount)) > Math.PI,
},
];
}
}
return {
segments: [
...model.segments
.slice(0, segmentIndex)
.map((value) => structuredClone(value)),
...replacements,
...model.segments
.slice(segmentIndex + 1)
.map((value) => structuredClone(value)),
],
};
}
function reverseGeometry(segment: GeometrySegment): GeometrySegment {
switch (segment.kind) {
case "L":
return {
kind: "L",
from: clonePoint(segment.to),
to: clonePoint(segment.from),
};
case "C":
return {
kind: "C",
from: clonePoint(segment.to),
control1: clonePoint(segment.control2),
control2: clonePoint(segment.control1),
to: clonePoint(segment.from),
};
case "Q":
return {
kind: "Q",
from: clonePoint(segment.to),
control: clonePoint(segment.control),
to: clonePoint(segment.from),
};
case "A":
return {
...segment,
from: clonePoint(segment.to),
to: clonePoint(segment.from),
sweep: !segment.sweep,
};
}
}
export function reversePath(model: PathModel): PathModel {
const output: PathSegment[] = [];
let move: MoveSegment | null = null;
let body: Array<GeometrySegment | CloseSegment> = [];
const flush = () => {
if (!move) return;
const closed = body.at(-1)?.kind === "Z";
if (
body.some(
(segment, index) => segment.kind === "Z" && index !== body.length - 1,
)
) {
throw new TypeError("A close command must end its subpath");
}
const geometry = body.filter(
(segment): segment is GeometrySegment => segment.kind !== "Z",
);
const start = geometry.length
? clonePoint(geometry.at(-1)!.to)
: clonePoint(move.to);
output.push({ kind: "M", to: start });
for (let index = geometry.length - 1; index >= 0; index -= 1) {
output.push(reverseGeometry(geometry[index]!));
}
if (closed) {
const from = geometry.length
? clonePoint(geometry[0]!.from)
: clonePoint(start);
output.push({ kind: "Z", from, to: clonePoint(start) });
}
move = null;
body = [];
};
for (const segment of model.segments) {
if (segment.kind === "M") {
flush();
move = structuredClone(segment);
} else {
if (!move) {
throw new TypeError("Draw segment encountered before moveto");
}
body.push(structuredClone(segment));
}
}
flush();
return { segments: output };
}
interface TransformedArcAxes {
rx: number;
ry: number;
rotation: number;
}
function transformedArcAxes(
arc: ArcSegment,
matrix: Matrix,
): TransformedArcAxes | null {
if (determinant(matrix) === 0) return null;
const rotationRadians = (arc.rotation * Math.PI) / 180;
const cosine = Math.cos(rotationRadians);
const sine = Math.sin(rotationRadians);
const basis1 = {
x: matrix.a * (arc.rx * cosine) + matrix.c * (arc.rx * sine),
y: matrix.b * (arc.rx * cosine) + matrix.d * (arc.rx * sine),
};
const basis2 = {
x: matrix.a * (-arc.ry * sine) + matrix.c * (arc.ry * cosine),
y: matrix.b * (-arc.ry * sine) + matrix.d * (arc.ry * cosine),
};
const q11 = basis1.x ** 2 + basis2.x ** 2;
const q12 = basis1.x * basis1.y + basis2.x * basis2.y;
const q22 = basis1.y ** 2 + basis2.y ** 2;
const discriminant = Math.hypot(q11 - q22, 2 * q12);
return {
rx: Math.sqrt(Math.max(0, (q11 + q22 + discriminant) / 2)),
ry: Math.sqrt(Math.max(0, (q11 + q22 - discriminant) / 2)),
rotation: ((Math.atan2(2 * q12, q11 - q22) * 90) / Math.PI + 360) % 360,
};
}
export function transformPath(model: PathModel, matrix: Matrix): PathModel {
return {
segments: model.segments.map((segment): PathSegment => {
switch (segment.kind) {
case "M":
return { kind: "M", to: applyToPoint(matrix, segment.to) };
case "L":
return {
kind: "L",
from: applyToPoint(matrix, segment.from),
to: applyToPoint(matrix, segment.to),
};
case "C":
return {
kind: "C",
from: applyToPoint(matrix, segment.from),
control1: applyToPoint(matrix, segment.control1),
control2: applyToPoint(matrix, segment.control2),
to: applyToPoint(matrix, segment.to),
};
case "Q":
return {
kind: "Q",
from: applyToPoint(matrix, segment.from),
control: applyToPoint(matrix, segment.control),
to: applyToPoint(matrix, segment.to),
};
case "A": {
const axes = transformedArcAxes(segment, matrix);
if (!axes || axes.rx === 0 || axes.ry === 0) {
throw new Error(
"A singular transform cannot bake an elliptical arc safely",
);
}
return {
...segment,
from: applyToPoint(matrix, segment.from),
to: applyToPoint(matrix, segment.to),
...axes,
sweep: determinant(matrix) < 0 ? !segment.sweep : segment.sweep,
};
}
case "Z":
return {
kind: "Z",
from: applyToPoint(matrix, segment.from),
to: applyToPoint(matrix, segment.to),
};
}
}),
};
}