1060 lines
30 KiB
TypeScript
1060 lines
30 KiB
TypeScript
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),
|
||
};
|
||
}
|
||
}),
|
||
};
|
||
}
|