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 = []; 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), }; } }), }; }