diff --git a/src/app/common/array-utils.ts b/src/app/common/array-utils.ts index f847082..3daf199 100644 --- a/src/app/common/array-utils.ts +++ b/src/app/common/array-utils.ts @@ -58,6 +58,10 @@ export function randomItem(arr: Readonly): T { return arr[Math.floor(Math.random() * arr.length)]; } +export function lastItem(arr: Readonly): T { + return arr[arr.length - 1]; +} + /* Randomize array in-place using Durstenfeld shuffle algorithm */ export function shuffle(arr: Array): Array { for (let i = arr.length; --i > 0; ) { diff --git a/src/app/svg-path-editor/editor/editor.component.ts b/src/app/svg-path-editor/editor/editor.component.ts index d76bb0c..3ec0890 100644 --- a/src/app/svg-path-editor/editor/editor.component.ts +++ b/src/app/svg-path-editor/editor/editor.component.ts @@ -348,7 +348,7 @@ export class EditorComponent implements OnInit { const last = this.path[lastIndex]; const next: Path.PathItem = Path.createPathNode(command, SAMPLE_PATH_ITEMS[command]); Path.translate(next, Path.getX(last), Path.getY(last)); - next.outputAsRelative = true; + next.outputAsRelative = last?.outputAsRelative; const nextIndex = lastIndex + 1; this.path = Path.appendAt(this.path, nextIndex, next); diff --git a/src/app/svg-path/arc-node.ts b/src/app/svg-path/arc-node.ts new file mode 100644 index 0000000..cd4d9bd --- /dev/null +++ b/src/app/svg-path/arc-node.ts @@ -0,0 +1,209 @@ +// tslint:disable: variable-name +import { EllipticalArc } from './command'; +import { getX, getY, PathNode } from './node'; + +// In general, the angle between two vectors (ux, uy) and (vx, vy) can be computed as +// +- arccos(dot(u, v) / (u.length * v.length), +// where the +- sign is the sign of (ux * vy − uy * vx). +function twoVectorsAngle(ux: number, uy: number, vx: number, vy: number): number { + // const ul = Math.sqrt(ux * ux + uy * uy); + // const vl = Math.sqrt(vx * vx + vy * vy); + // const dot = ux * vx + uy * vy; + // const sign = ux * vy - uy * vx < 0 ? -1 : 1; // Math.sign(0) returns 0 + // return sign * Math.acos(Math.max(-1, Math.min(1, dot / (ul * vl)))); + + const a2 = Math.atan2(uy, ux); + const a1 = Math.atan2(vy, vx); + const sign = a1 > a2 ? -1 : 1; + const angle1 = a1 - a2; + const angle2 = angle1 + sign * Math.PI * 2; + + return (Math.abs(angle2) < Math.abs(angle1)) ? angle2 : angle1; +} + +export type EllipseParams = { + cx: number; + cy: number; + rx: number; + ry: number; + phi: number; +}; + +export type EllipticalArcParams = EllipseParams & { + theta?: number; + deltaTheta?: number; +}; + +// Specification: https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter +export function getCenterParams(node: Readonly): EllipticalArcParams { + // Given the following variables: + // x1 y1 x2 y2 fA fS rx ry phi + const x1 = getX(node.prev); + const y1 = getY(node.prev); + const x2 = node.x; + const y2 = node.y; + + const fA = node.largeArcFlag; + const fB = node.sweepFlag; + + const phi = node.angle * Math.PI / 180; + + // Correction Step 1: Ensure radii are positive + let rx = Math.abs(node.rx); + let ry = Math.abs(node.ry); + // Correction Step 2: Ensure radii are non-zero + // If rx = 0 or ry = 0, then treat this as a straight line from (x1, y1) to (x2, y2) and stop. + if (rx === 0 || ry === 0) { + return { cx: (x1 + x2) / 2, cy: (y1 + y2) / 2, rx, ry, phi }; + } + + // Step 1: Compute (x1′, y1′) + const cosPhi = Math.cos(phi); + const sinPhi = Math.sin(phi); + const dx = (x1 - x2) / 2; + const dy = (y1 - y2) / 2; + + const x1_ = cosPhi * dx + sinPhi * dy; + const y1_ = -sinPhi * dx + cosPhi * dy; + + // Correction Step 3: Ensure radii are large enough + const L = (x1_ * x1_ * ry * ry + y1_ * y1_ * rx * rx) / (rx * rx * ry * ry); + if (L > 1) { + // Scale up + rx *= Math.sqrt(L); + ry *= Math.sqrt(L); + } + + // Step 2: Compute (cx′, cy′) + const M = (fA === fB ? -1 : 1) * Math.sqrt( + Math.max(rx * rx * ry * ry - rx * rx * y1_ * y1_ - ry * ry * x1_ * x1_, 0) / (rx * rx * y1_ * y1_ + ry * ry * x1_ * x1_) + ); + const cx_ = M * rx * y1_ / ry; + const cy_ = M * -ry * x1_ / rx; + + // Step 3: Compute (cx, cy) from (cx′, cy′) + const cx = (cosPhi * cx_ - sinPhi * cy_) || 0; + const cy = (sinPhi * cx_ + cosPhi * cy_) || 0; + + // Step 4: Compute theta and deltaTheta + const ux = (x1_ - cx_) / rx; + const uy = (y1_ - cy_) / ry; + const vx = -(x1_ + cx_) / rx; + const vy = -(y1_ + cy_) / ry; + const theta = twoVectorsAngle(1, 0, ux, uy) || 0; + + let deltaTheta = twoVectorsAngle(ux, uy, vx, vy) || 0; + if (fB) { + // deltaTheta should be >= 0 + if (deltaTheta < 0) { + deltaTheta += 2 * Math.PI; + } + } else { + // deltaTheta should be <= 0 + if (deltaTheta > 0) { + deltaTheta -= 2 * Math.PI; + } + } + + return { cx: cx + (x1 + x2) / 2, cy: cy + (y1 + y2) / 2, rx, ry, phi, theta, deltaTheta }; +} + +export function getEllipsePoint(ellipse: Readonly, theta: number) { + // An arbitrary point (x, y) on the elliptical arc can be described by the 2-dimensional matrix equation + // https://www.w3.org/TR/SVG/implnote.html#ArcParameterizationAlternatives + const cosPhi = Math.cos(ellipse.phi); + const sinPhi = Math.sin(ellipse.phi); + + const x1 = ellipse.rx * Math.cos(theta); + const y1 = ellipse.ry * Math.sin(theta); + + const x1_ = cosPhi * x1 - sinPhi * y1; + const y1_ = sinPhi * x1 + cosPhi * y1; + + const x = x1_ + ellipse.cx; + const y = y1_ + ellipse.cy; + + return { x, y }; +} + +// Derivative of +// cos'(theta) = -sin(theta) +// sin'(theta) = cos(theta) +// +// x'(theta) = cosPhi * rx * cos'(theta) - sinPhi * ry * sin'(theta); +// y'(theta) = sinPhi * rx * cos'(theta) + cosPhi * ry * sin'(theta); +// +// x'(theta) = -cosPhi * rx * Math.sin(theta) - sinPhi * ry * Math.cos(theta); +// y'(theta) = -sinPhi * rx * Math.sin(theta) + cosPhi * ry * Math.cos(theta); +export function getEllipseTangent(ellipse: Readonly, theta: number) { + const cosPhi = Math.cos(ellipse.phi); + const sinPhi = Math.sin(ellipse.phi); + + const dx1 = -ellipse.rx * Math.sin(theta); + const dy1 = ellipse.ry * Math.cos(theta); + + const x = cosPhi * dx1 - sinPhi * dy1; + const y = sinPhi * dx1 + cosPhi * dy1; + + return { x, y }; +} + +export function ellipticalArcToCurve( + x0: number, y0: number, + x: number, y: number, + ellipse: Readonly, + theta1: number, theta2: number): PathNode { + const t1 = getEllipseTangent(ellipse, theta1); + const t2 = getEllipseTangent(ellipse, theta2); + + const t = 4 * Math.tan((theta2 - theta1) / 4) / 3; + + const x1 = x0 + t * t1.x; + const y1 = y0 + t * t1.y; + const x2 = x - t * t2.x; + const y2 = y - t * t2.y; + + return { name: 'C', x1, y1, x2, y2, x, y }; +} + +export function approximateEllipticalArc(node: Readonly): PathNode[] { + const ellipse = getCenterParams(node); + if (ellipse.rx <= 0 || ellipse.ry <= 0 || !ellipse.deltaTheta) { + // Treat this as a straight line and stop. + return [{ name: 'L' , x: node.x, y: node.y }]; + } + + const x0 = getX(node.prev); + const y0 = getY(node.prev); + + // Determine the number of curves to use in the approximation. + const { theta, deltaTheta } = ellipse; + if (Math.abs(deltaTheta) > 4 * Math.PI / 3) { + // Three-part split. + const theta1 = theta + deltaTheta / 3; + const theta2 = theta + 2 * deltaTheta / 3; + const theta3 = theta + deltaTheta; + + const p1 = getEllipsePoint(ellipse, theta1); + const p2 = getEllipsePoint(ellipse, theta2); + + return [ + ellipticalArcToCurve(x0, y0, p1.x, p1.y, ellipse, theta, theta1), + ellipticalArcToCurve(p1.x, p1.y, p2.x, p2.y, ellipse, theta1, theta2), + ellipticalArcToCurve(p2.x, p2.y, node.x, node.y, ellipse, theta2, theta3) + ]; + } else if (Math.abs(deltaTheta) > 2 * Math.PI / 3) { + // Two-part split. + const theta1 = theta + deltaTheta / 2; + const theta2 = theta + deltaTheta; + + const p1 = getEllipsePoint(ellipse, theta1); + + return [ + ellipticalArcToCurve(x0, y0, p1.x, p1.y, ellipse, theta, theta1), + ellipticalArcToCurve(p1.x, p1.y, node.x, node.y, ellipse, theta1, theta2) + ]; + } else { + return [ellipticalArcToCurve(x0, y0, node.x, node.y, ellipse, theta, theta + deltaTheta)]; + } +} diff --git a/src/app/svg-path/command.ts b/src/app/svg-path/command.ts new file mode 100644 index 0000000..3778c02 --- /dev/null +++ b/src/app/svg-path/command.ts @@ -0,0 +1,256 @@ +import { formatDecimal } from 'src/app/common/math-utils'; +import { SubType } from 'src/app/common/types'; + +export type MoveTo = { + name: 'M'; + x: number; + y: number; +}; + +export type LineTo = { + name: 'L'; + x: number; + y: number; +}; + +export type HLineTo = { + name: 'H'; + x: number; +}; + +export type VLineTo = { + name: 'V'; + y: number; +}; + +export type ClosePath = { + name: 'Z'; +}; + +export type CurveTo = { + name: 'C'; + x1: number; + y1: number; + x2: number; + y2: number; + x: number; + y: number; +}; + +export type SmoothCurveTo = { + name: 'S'; + x2: number; + y2: number; + x: number; + y: number; +}; + +export type QCurveTo = { + name: 'Q'; + x1: number; + y1: number; + x: number; + y: number; +}; + +export type SmoothQCurveTo = { + name: 'T'; + x: number; + y: number; +}; + +export type EllipseShape = { + rx: number; + ry: number; + angle: number; + largeArcFlag: boolean; + sweepFlag: boolean; +}; + +export type EllipticalArc = EllipseShape & { + name: 'A'; + x: number; + y: number; +}; + +export type DrawTo = MoveTo | LineTo | HLineTo | VLineTo | ClosePath | CurveTo | SmoothCurveTo | QCurveTo | SmoothQCurveTo | EllipticalArc; +export type DrawCommand = DrawTo['name']; + +export type DrawNumberParam = + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + ; + +export type DrawBooleanParam = + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + | keyof SubType + ; + +export type DrawParam = DrawNumberParam | DrawBooleanParam; + +// Type Guards: +export function isMoveTo(item: DrawTo): item is MoveTo { + return item.name === 'M'; +} + +export function isLineTo(item: DrawTo): item is LineTo { + return item.name === 'L'; +} + +export function isHLineTo(item: DrawTo): item is HLineTo { + return item.name === 'H'; +} + +export function isVLineTo(item: DrawTo): item is VLineTo { + return item.name === 'V'; +} + +export function isClosePath(item: DrawTo): item is ClosePath { + return item.name === 'Z'; +} + +export function isCurveTo(item: DrawTo): item is CurveTo { + return item.name === 'C'; +} + +export function isSmoothCurveTo(item: DrawTo): item is SmoothCurveTo { + return item.name === 'S'; +} + +export function isQCurveTo(item: DrawTo): item is QCurveTo { + return item.name === 'Q'; +} + +export function isSmoothQCurveTo(item: DrawTo): item is SmoothQCurveTo { + return item.name === 'T'; +} + +export function isEllipticalArc(item: DrawTo): item is EllipticalArc { + return item.name === 'A'; +} + +export function hasControlPoint1(item: DrawTo): item is CurveTo | QCurveTo { + return isCurveTo(item) || isQCurveTo(item); +} + +export function hasControlPoint2(item: DrawTo): item is CurveTo | SmoothCurveTo { + return isCurveTo(item) || isSmoothCurveTo(item); +} + +export function isBezierCurve(item: DrawTo): item is CurveTo | SmoothCurveTo | QCurveTo | SmoothQCurveTo { + return isCurveTo(item) || isSmoothCurveTo(item) || isQCurveTo(item) || isSmoothQCurveTo(item); +} + +export const COMMAND_FULL_NAMES: { [key in DrawCommand]: string } = { + M: 'MoveTo', + L: 'LineTo', + H: 'Horizontal LineTo', + V: 'Vertical LineTo', + C: 'Cubic Bézier Curve', + S: 'Smooth Cubic Bézier Curve', + Q: 'Quadratic Bézier Curve', + T: 'Smooth Quadratic Bézier Curve', + A: 'Elliptical Arc Curve', + Z: 'ClosePath', +} as const; + +function formatDigit(value: number, fractionDigits?: number) { + return ' ' + (fractionDigits < 0 ? value.toString() : formatDecimal(value, fractionDigits)); +} + +export function formatParams(item: Readonly, x0: number, y0: number, fractionDigits?: number): string { + let buf = ''; + if (!isClosePath(item)) { + if (hasControlPoint1(item)) { + buf += formatDigit(item.x1 - x0, fractionDigits); + buf += formatDigit(item.y1 - y0, fractionDigits); + } + if (hasControlPoint2(item)) { + buf += formatDigit(item.x2 - x0, fractionDigits); + buf += formatDigit(item.y2 - y0, fractionDigits); + } + if (isEllipticalArc(item)) { + buf += formatDigit(item.rx, fractionDigits); + buf += formatDigit(item.ry, fractionDigits); + buf += formatDigit(item.angle, fractionDigits); + buf += ' ' + (item.largeArcFlag ? '1' : '0') + (item.sweepFlag ? '1' : '0'); + } + + if (!isVLineTo(item)) { + buf += formatDigit(item.x - x0, fractionDigits); + } + if (!isHLineTo(item)) { + buf += formatDigit(item.y - y0, fractionDigits); + } + } + return buf; +} + +/** + * Returns a string representing the draw command in absolute form. + * @param item SVG path single draw command + * @param fractionDigits Number of digits after the decimal point. Must be in the range 0 - 20, inclusive. + */ +export function asString(item: Readonly, fractionDigits = -1): string { + return item.name + formatParams(item, 0, 0, fractionDigits); +} + +export function translate(item: DrawTo, dx: number, dy: number) { + if (!isClosePath(item)) { + if (!isVLineTo(item)) { + item.x += dx; + } + if (!isHLineTo(item)) { + item.y += dy; + } + if (hasControlPoint1(item)) { + item.x1 += dx; + item.y1 += dy; + } + if (hasControlPoint2(item)) { + item.x2 += dx; + item.y2 += dy; + } + } +} + +export function translateStopPoint(item: DrawTo, dx: number, dy: number) { + if (!isClosePath(item)) { + if (!isVLineTo(item)) { + item.x += dx; + } + if (!isHLineTo(item)) { + item.y += dy; + } + } +} + +export function translateControlPoint1(item: DrawTo, dx: number, dy: number) { + if (hasControlPoint1(item)) { + item.x1 += dx; + item.y1 += dy; + } +} + +export function translateControlPoint2(item: DrawTo, dx: number, dy: number) { + if (hasControlPoint2(item)) { + item.x2 += dx; + item.y2 += dy; + } +} diff --git a/src/app/svg-path/curve-node.ts b/src/app/svg-path/curve-node.ts new file mode 100644 index 0000000..4c1d5fa --- /dev/null +++ b/src/app/svg-path/curve-node.ts @@ -0,0 +1,95 @@ +import { + CurveTo, + hasControlPoint1, + hasControlPoint2, + isCurveTo, + isQCurveTo, + isSmoothCurveTo, + isSmoothQCurveTo, + QCurveTo, + SmoothCurveTo, + SmoothQCurveTo +} from './command'; + +import { + getX, + getY, + PathNode +} from './node'; + +export type CurveNode = PathNode & (CurveTo | QCurveTo | SmoothCurveTo | SmoothQCurveTo); +export type SmoothCurveNode = PathNode & (SmoothCurveTo | SmoothQCurveTo); + +function isReflectable(node: Readonly , prev: Readonly ): prev is Readonly{ + return prev.name === node.name || + (isCurveTo(prev) && isSmoothCurveTo(node)) || + (isQCurveTo(prev) && isSmoothQCurveTo(node)); +} + +/** + * The S/s and T/t commands indicate that the first control point of the given cubic/quadratic + * Bézier curve is calculated by reflecting the previous path segment's final control point + * relative to the current point. + * + * The exact math is as follows. + * If the current point is (curx, cury) + * and the final control point of the previous path segment is (oldx2, oldy2), + * then the first control point of the current path segment (reflected point) is: + * + * (newx1, newy1) = (curx - (oldx2 - curx), cury - (oldy2 - cury)) = (2*curx - oldx2, 2*cury - oldy2) + */ +export function getReflectedX1(node: Readonly): number { + const prev = node.prev; + + let x = getX(prev); + if (prev && isReflectable(node, prev)) { + x += x - getLastControlX(prev); + } + return x; +} + +export function getReflectedY1(node: Readonly): number { + const prev = node.prev; + + let y = getY(prev); + if (prev && isReflectable(node, prev)) { + y += y - getLastControlY(prev); + } + return y; +} + +export function getFirstControlX(node: Readonly): number { + if (hasControlPoint1(node)) { + return node.x1; + } else { + return getReflectedX1(node); + } +} + +export function getFirstControlY(node: Readonly): number { + if (hasControlPoint1(node)) { + return node.y1; + } else { + return getReflectedY1(node); + } +} + +export function getLastControlX(node: Readonly): number { + if (hasControlPoint2(node)) { + return node.x2; + } else if (isQCurveTo(node)) { + return node.x1; + } else { + return getReflectedX1(node); + } +} + +export function getLastControlY(node: Readonly): number { + if (hasControlPoint2(node)) { + return node.y2; + } else if (isQCurveTo(node)) { + return node.y1; + } else { + return getReflectedY1(node); + } +} diff --git a/src/app/svg-path/index.ts b/src/app/svg-path/index.ts new file mode 100644 index 0000000..21ba274 --- /dev/null +++ b/src/app/svg-path/index.ts @@ -0,0 +1,7 @@ +export * from './command'; +export * from './parser'; +export * from './node'; +export * from './arc-node'; +export * from './curve-node'; +export * from './transform'; +export * from './promoter'; diff --git a/src/app/svg-path/interpolator.ts b/src/app/svg-path/interpolator.ts new file mode 100644 index 0000000..e8a0917 --- /dev/null +++ b/src/app/svg-path/interpolator.ts @@ -0,0 +1,142 @@ +import { lastItem } from '../common/array-utils'; +import { approximateEllipticalArc } from './arc-node'; +import { ClosePath, EllipticalArc, isClosePath, isCurveTo, isEllipticalArc, isMoveTo, MoveTo } from './command'; +import { getGroups, getX, getY, makePath, PathNode } from './node'; +import { canPromoteToCurve, promoteToCurve } from './promoter'; +import { bisect, split } from './splitter'; + +type NormalizedNode = Exclude; + +function stretch(pathGroup: PathNode[], size: number): PathNode[] { + const first = pathGroup[0]; + if (!(first && isMoveTo(first))) { + throw new Error('First node in a path group should be MoveTo!'); + } + + const items: PathNode[] = [{ ...first }]; + + const a = pathGroup.length - 1; + const b = size - 1; + + if (a > 0) { + // Splitting existing nodes. + const r = b % a; + const n = (b - r) / a; + for (let i = 1; i <= a; i++) { + const node = pathGroup[i]; + const x = (i <= r ? n + 1 : n); + if (x > 1) { + items.push(...split(node, x)); + } else { + items.push({ ...node }); + } + } + } else { + // Adding empty nodes. + for (let i = 0; i < b; i++) { + items.push({ name: 'L', x: first.x, y: first.y }); + } + } + return makePath(items); +} + +function align(pathGroupA: PathNode[], pathGroupB: PathNode[]) { + const count = Math.max(pathGroupA.length, pathGroupB.length); + + if (pathGroupA.length < count) { + pathGroupA = stretch(pathGroupA, count); + } else if (pathGroupB.length < count) { + pathGroupB = stretch(pathGroupB, count); + } + + const itemsA: PathNode[] = []; + const itemsB: PathNode[] = []; + + for (let i = 0; i < count; i++) { + const a = pathGroupA[i]; + const b = pathGroupB[i]; + if (a.name === b.name) { + itemsA.push({ ...a }); + itemsB.push({ ...b }); + } else { + itemsA.push(canPromoteToCurve(a) ? promoteToCurve(a) : { ...a }); + itemsB.push(canPromoteToCurve(b) ? promoteToCurve(b) : { ...b }); + } + } + + return [makePath(itemsA), makePath(itemsB)]; +} + +// function toCurves(path: PathNode[]): PathNode[] { +// const items: PathNode[] = []; +// for (const node of path) { +// if (isEllipticalArc(node)) { +// items.concat(approximateEllipticalArc(node)); +// } else if (isClosePath(node)) { +// const prev = node.prev; +// const x0 = getX(prev); +// const y0 = getY(prev); +// const x = getX(node); +// const y = getY(node); +// if (x0 !== x || y0 !== y) { +// items.push(promoteToCurve({ name: 'L', x, y, prev })); +// } +// items.push({ name: 'Z'}); +// } else { +// items.push(canPromoteToCurve(node) ? promoteToCurve(node) : { ...node }); +// } +// } +// return makePath(items); +// } + +function normalize(path: PathNode[]): NormalizedNode[] { + const items: NormalizedNode[] = []; + + // Let's start with a MoveTo node. + if (path.length <= 0 || !isMoveTo(path[0])) { + items.push({ name: 'M', x: 0, y: 0 }); + } + + // Get rid of EllipticalArcs and ClosePaths. + for (const node of path) { + if (isEllipticalArc(node)) { + items.push(...approximateEllipticalArc(node)); + } else if (isClosePath(node)) { + const prev = node.prev; + const x0 = getX(prev); + const y0 = getY(prev); + const x = getX(node); + const y = getY(node); + if (x0 !== x || y0 !== y) { + items.push({ name: 'L', x, y }); + } + } else { + items.push({ ...node }); + } + } + return makePath(items); +} + +export function interpolate(src: PathNode[], dst: PathNode[]) { + const srcGroups = getGroups(normalize(src)); + const dstGroups = getGroups(normalize(dst)); + + while (srcGroups.length < dstGroups.length) { + const prev = lastItem(lastItem(srcGroups)); + srcGroups.push([{ name: 'M', x: getX(prev), y: getY(prev), prev}]); + } + while (dstGroups.length < srcGroups.length) { + const prev = lastItem(lastItem(dstGroups)); + dstGroups.push([{ name: 'M', x: getX(prev), y: getY(prev), prev}]); + } + + const itemsA: PathNode[] = []; + const itemsB: PathNode[] = []; + for (let i = 0; i < srcGroups.length; i++) { + const [a, b] = align(srcGroups[i], dstGroups[i]); + itemsA.push(...a); + itemsB.push(...b); + } + + return [makePath(itemsA), makePath(itemsB)]; +} diff --git a/src/app/svg-path/item.ts b/src/app/svg-path/item.ts new file mode 100644 index 0000000..f505495 --- /dev/null +++ b/src/app/svg-path/item.ts @@ -0,0 +1,15 @@ +import { PathNode, createPathNode } from './node'; +import { DrawToken } from './parser'; + +export function getPathNodesA(tokens: DrawToken[]) { + const path: PathNode[] = []; + for (let i = 0; i < tokens.length; i++) { + path[i] = createPathNode(tokens[i], path[i - 1]); + } + return path; +} + +export function getPathNodesB(tokens: DrawToken[]) { + let node: PathNode | undefined; + return tokens.map(token => node = createPathNode(token, node)); +} diff --git a/src/app/svg-path/node.ts b/src/app/svg-path/node.ts new file mode 100644 index 0000000..88e5469 --- /dev/null +++ b/src/app/svg-path/node.ts @@ -0,0 +1,106 @@ +import { DrawTo, formatParams, isClosePath, isHLineTo, isMoveTo, isVLineTo, MoveTo } from './command'; +import { DrawToken } from './parser'; + +export type PathNode = DrawTo & { prev?: PathNode}; + +function getMoveTo(item?: Readonly): Readonly | undefined { + for (let node = item; node; node = node.prev) { + if (isMoveTo(node)) { + return node; + } + } +} + +export function getX(item?: Readonly): number { + if (item) { + if (isClosePath(item)) { + return getX(getMoveTo(item.prev)); + } else if (isVLineTo(item)) { + return getX(item.prev); + } else { + return item.x; + } + } + return 0; +} + +export function getY(item?: Readonly): number { + if (item) { + if (isClosePath(item)) { + return getY(getMoveTo(item.prev)); + } else if (isHLineTo(item)) { + return getY(item.prev); + } else { + return item.y; + } + } + return 0; +} + +export function asRelativeString(item: Readonly, fractionDigits?: number): string { + return item.name.toLowerCase() + formatParams(item, getX(item.prev), getY(item.prev), fractionDigits); +} + +export function createPathNode({ name, args, relative }: Readonly, prev?: PathNode): PathNode { + const X0 = relative ? getX(prev) : 0; + const Y0 = relative ? getY(prev) : 0; + + switch (name) { + case 'Z': + return { name, prev }; + case 'H': + return { name, x: +args[0] + X0, prev }; + case 'V': + return { name, y: +args[0] + Y0, prev }; + case 'M': + case 'L': + case 'T': + return { name, x: +args[0] + X0, y: +args[1] + Y0, prev }; + case 'Q': + return { name, x1: +args[0] + X0, y1: +args[1] + Y0, x: +args[2] + X0, y: +args[3] + Y0, prev }; + case 'S': + return { name, x2: +args[0] + X0, y2: +args[1] + Y0, x: +args[2] + X0, y: +args[3] + Y0, prev }; + case 'C': + return { name, x1: +args[0] + X0, y1: +args[1] + Y0, x2: +args[2] + X0, y2: +args[3] + Y0, x: +args[4] + X0, y: +args[5] + Y0, prev }; + case 'A': + return { name, rx: +args[0], ry: +args[1], angle: +args[2], largeArcFlag: +args[3] === 1, sweepFlag: +args[4] === 1, + x: +args[5] + X0, y: +args[6] + Y0, prev }; + } +} + +/** + * Connects items and casts the DrawTo array into a PathNode array. + * @param items array to cast + */ +export function makePath(items: DrawTo[]): PathNode[] { + let prev: PathNode | undefined; + for (const node of items as PathNode[]) { + node.prev = prev; + prev = node; + } + return items; +} + +// Split into logic groups +export function getGroups(items: PathNode[]): PathNode[][] { + const groups: PathNode[][] = []; + let next: PathNode[]; + for (const item of items) { + if (!next || isMoveTo(item)) { + if (next) { + groups.push(next); + } + next = [item]; + } else { + next.push(item); + } + if (isClosePath(item)) { + groups.push(next); + next = undefined; + } + } + if (next) { + groups.push(next); + } + return groups; +} diff --git a/src/app/svg-path/parser.ts b/src/app/svg-path/parser.ts new file mode 100644 index 0000000..16eb82a --- /dev/null +++ b/src/app/svg-path/parser.ts @@ -0,0 +1,147 @@ +import { DrawCommand } from './command'; + +const RE_COMMAND = /([MLHVZCSQTA])/gi; +const RE_FLAG = /[01]/; +const RE_SIGNED = /[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?/; +const RE_UNSIGNED = /[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?/; + +/** + * [Path Doc](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths) + */ +const REXPS: {[key in DrawCommand]: Readonly} = { + // Move To: + // M x y + // m dx dy + M: [RE_SIGNED, RE_SIGNED], + + // Line To: + // L x y + // l dx dy + L: [RE_SIGNED, RE_SIGNED], + + // Horizontal Line To: + // H x + // h dx + H: [RE_SIGNED], + + // Vertical Line To: + // V y + // v dy + V: [RE_SIGNED], + + // Close Path. + Z: [], + + // Bézier curves: + + // Curve To: + // C x1 y1, x2 y2, x y + // c dx1 dy1, dx2 dy2, dx dy + C: [RE_SIGNED, RE_SIGNED, RE_SIGNED, RE_SIGNED, RE_SIGNED, RE_SIGNED], + + // Shortcut Curve To: + // S x2 y2, x y + // s dx2 dy2, dx dy + S: [RE_SIGNED, RE_SIGNED, RE_SIGNED, RE_SIGNED], + + // Quadratic Curve To: + // Q x1 y1, x y + // q dx1 dy1, dx dy + Q: [RE_SIGNED, RE_SIGNED, RE_SIGNED, RE_SIGNED], + + // Shortcut Quadratic Curve To: + // T x y + // t dx dy + T: [RE_SIGNED, RE_SIGNED], + + // Arc To: + // A rx ry x-axis-rotation large-arc-flag sweep-flag x y + // a rx ry x-axis-rotation large-arc-flag sweep-flag dx dy + A: [RE_UNSIGNED, RE_UNSIGNED, RE_SIGNED, RE_FLAG, RE_FLAG, RE_SIGNED, RE_SIGNED], +}; + +/** + * Helper class. + */ +class Reader { + // tslint:disable-next-line: variable-name + private _data = ''; + + set data(value: string) { + this._data = value.trim(); + } + + get data() { + return this._data; + } + + moveTo(start: number) { + this.data = this.data.slice(start); + } + + read(exp: RegExp): string | null { + exp.lastIndex = 0; + const m = exp.exec(this.data); + if (m !== null) { + const value = m[0]; + this.moveTo(m.index + value.length); + return value; + } + return null; + } + + readAll(exps: Readonly): string[] | null { + const buf: string[] = []; + for (const exp of exps) { + const value = this.read(exp); + if (value !== null) { + buf.push(value); + } else { + return null; + } + } + return buf; + } +} + +export type DrawToken = { + name: DrawCommand; + args: string[]; + relative?: boolean; +}; + +export function getTokens(pathData: string): DrawToken[] { + const tokens: DrawToken[] = []; + + // Split by command. + const split = pathData.split(RE_COMMAND); + const reader = new Reader(); + + for (let i = 2; i < split.length; i += 2) { + let command = split[i - 1]; + reader.data = split[i]; + + while (true) { + const name = command.toUpperCase() as DrawCommand; + const relative = name !== command; + const args = reader.readAll(REXPS[name]); + if (args !== null) { + tokens.push({ name, relative, args }); + } else { + // console.warn('Couldn\'t properly parse this expression:', reader.data); + break; + } + if (!reader.data || args.length === 0) { + break; + } else { + if (command === 'M') { + command = 'L'; + } else if (command === 'm') { + command = 'l'; + } + } + } + } + + return tokens; +} diff --git a/src/app/svg-path/promoter.ts b/src/app/svg-path/promoter.ts new file mode 100644 index 0000000..c14cba3 --- /dev/null +++ b/src/app/svg-path/promoter.ts @@ -0,0 +1,64 @@ +import { CurveTo, DrawTo, isHLineTo, isLineTo, isQCurveTo, isSmoothCurveTo, isSmoothQCurveTo, isVLineTo, QCurveTo } from './command'; +import { getFirstControlX, getFirstControlY, getReflectedX1, getReflectedY1 } from './curve-node'; +import { getX, getY, PathNode } from './node'; + +export function canPromoteToLine(item: Readonly): boolean { + return isHLineTo(item) || isVLineTo(item); +} + +export function canPromoteToQCurve(item: Readonly): boolean { + return canPromoteToLine(item) || isLineTo(item) || isSmoothQCurveTo(item); +} + +export function canPromoteToCurve(item: Readonly): boolean { + return canPromoteToQCurve(item) || isQCurveTo(item) || isSmoothCurveTo(item); +} + +export function promoteToQCurve(item: Readonly): QCurveTo { + if (isSmoothQCurveTo(item)) { + const x1 = getReflectedX1(item); + const y1 = getReflectedY1(item); + return { name: 'Q', x1, y1, x: item.x, y: item.y }; + } else { + // Treat it as a line. + const x0 = getX(item.prev); + const y0 = getY(item.prev); + const x = getX(item); + const y = getY(item); + const x1 = (x0 + x) / 2; + const y1 = (y0 + y) / 2; + return { name: 'Q', x1, y1, x, y }; + } +} + +export function promoteToCurve(item: Readonly): CurveTo { + if (isSmoothCurveTo(item)) { + const x1 = getReflectedX1(item); + const y1 = getReflectedY1(item); + return { name: 'C', x1, y1, x2: item.x2, y2: item.y2, x: item.x, y: item.y }; + } else { + const x0 = getX(item.prev); + const y0 = getY(item.prev); + if (isQCurveTo(item) || isSmoothQCurveTo(item)) { + const control2x = 2 * getFirstControlX(item); + const control2y = 2 * getFirstControlY(item); + // 1/3rd start + 2/3rd control + const x1 = (x0 + control2x) / 3; + const y1 = (y0 + control2y) / 3; + // 1/3rd stop + 2/3rd control + const x2 = (item.x + control2x) / 3; + const y2 = (item.y + control2y) / 3; + return { name: 'C', x1, y1, x2, y2, x: item.x, y: item.y }; + } else { + // Treat it as a line. + const x = getX(item); + const y = getY(item); + + const x1 = x0 + (x - x0) / 4; // (x + 2 * x0) / 3 + const y1 = y0 + (y - y0) / 4; // (y + 2 * y0) / 3 + const x2 = x0 + 3 * (x - x0) / 4; // (x0 + 2 * x) / 3 + const y2 = y0 + 3 * (y - y0) / 4; // (y0 + 2 * y) / 3 + return { name: 'C', x1, y1, x2, y2, x, y }; + } + } +} diff --git a/src/app/svg-path/splitter.ts b/src/app/svg-path/splitter.ts new file mode 100644 index 0000000..8a38a3c --- /dev/null +++ b/src/app/svg-path/splitter.ts @@ -0,0 +1,141 @@ +import { lerp } from '../common/math-utils'; +import { getCenterParams, getEllipsePoint } from './arc-node'; +import { + isBezierCurve, + isClosePath, + isCurveTo, + isEllipticalArc, + isHLineTo, + isLineTo, + isMoveTo, + isSmoothCurveTo, + isVLineTo +} from './command'; +import { getFirstControlX, getFirstControlY } from './curve-node'; +import { getX, getY, PathNode } from './node'; + +export function canSplit(item: Readonly): boolean { + return !(isMoveTo(item) || isClosePath(item)); +} + +export function bisect(item: Readonly, t = 1 / 2): PathNode[] { + const prev = item.prev; + if (isHLineTo(item)) { + const h1: PathNode = { name: 'H', x: lerp(getX(prev), item.x, t), prev }; + const h2: PathNode = { name: 'H', x: item.x, prev: h1 }; + return [h1, h2]; + } else if (isVLineTo(item)) { + const v1: PathNode = { name: 'V', y: lerp(getY(prev), item.y, t), prev }; + const v2: PathNode = { name: 'V', y: item.y, prev: v1 }; + return [v1, v2]; + } else { + const X0 = getX(prev); + const Y0 = getY(prev); + if (isBezierCurve(item)) { + // De Casteljau's Algorithm. https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm + const X1 = getFirstControlX(item); + const Y1 = getFirstControlY(item); + + if (isCurveTo(item) || isSmoothCurveTo(item)) { + const X2 = item.x2; + const Y2 = item.y2; + + const X12 = lerp(X1, X2, t); + const Y12 = lerp(Y1, Y2, t); + + const XB3 = item.x; + const YB3 = item.y; + const XB2 = lerp(X2, XB3, t); + const YB2 = lerp(Y2, YB3, t); + const XB1 = lerp(X12, XB2, t); + const YB1 = lerp(Y12, YB2, t); + + const XA1 = lerp(X0, X1, t); + const YA1 = lerp(Y0, Y1, t); + const XA2 = lerp(XA1, X12, t); + const YA2 = lerp(YA1, Y12, t); + const XA3 = lerp(XA2, XB1, t); + const YA3 = lerp(YA2, YB1, t); + + const c1: PathNode = { name: 'C', x1: XA1, y1: YA1, x2: XA2, y2: YA2, x: XA3, y: YA3, prev }; + const c2: PathNode = { name: 'C', x1: XB1, y1: YB1, x2: XB2, y2: YB2, x: XB3, y: YB3, prev: c1 }; + return [c1, c2]; + } else { + const XB2 = item.x; + const YB2 = item.y; + const XB1 = lerp(X1, XB2, t); + const YB1 = lerp(Y1, YB2, t); + + const XA1 = lerp(X0, X1, t); + const YA1 = lerp(Y0, Y1, t); + const XA2 = lerp(XA1, XB1, t); + const YA2 = lerp(YA1, YB1, t); + + const q1: PathNode = { name: 'Q', x1: XA1, y1: YA1, x: XA2, y: YA2, prev }; + const q2: PathNode = { name: 'Q', x1: XB1, y1: YB1, x: XB2, y: YB2, prev: q1 }; + return [q1, q2]; + } + } else if (isEllipticalArc(item)) { + const par = getCenterParams(item); + if (par.deltaTheta) { + const deltaTheta1 = lerp(0, par.deltaTheta, t); + const p1 = getEllipsePoint(par, par.theta + deltaTheta1); + const largeArcFlag1 = Math.abs(deltaTheta1) > Math.PI; + const largeArcFlag2 = Math.abs(par.deltaTheta - deltaTheta1) > Math.PI; + + const { rx, ry } = par; + const { angle, sweepFlag } = item; + + const a1: PathNode = { name: 'A', rx, ry, angle, sweepFlag, largeArcFlag: largeArcFlag1, x: p1.x, y: p1.y, prev }; + const a2: PathNode = { name: 'A', rx, ry, angle, sweepFlag, largeArcFlag: largeArcFlag2, x: item.x, y: item.y, prev: a1 }; + return [a1, a2]; + } else { + // Treat this as a straight line from (x1, y1) to (x2, y2). + const x = lerp(X0, item.x, t); + const y = lerp(Y0, item.y, t); + const { rx, ry, angle, sweepFlag } = item; + + const a1: PathNode = { name: 'A', rx, ry, angle, sweepFlag, largeArcFlag: false, x, y, prev }; + const a2: PathNode = { name: 'A', rx, ry, angle, sweepFlag, largeArcFlag: false, x: item.x, y: item.y, prev: a1 }; + return [a1, a2]; + } + } else if (isLineTo(item)) { + const l1: PathNode = { name: 'L', x: lerp(X0, item.x, t), y: lerp(Y0, item.y, t), prev }; + const l2: PathNode = { name: 'L', x: item.x, y: item.y, prev: l1 }; + return [l1, l2]; + } else { + return [{ ...item }]; + } + } +} + +export function trisect(item: Readonly, t = 1 / 3): PathNode[] { + const arr = bisect(item, t); + if (arr.length > 1) { + return [arr[0], ...bisect(arr[1])]; + } + return arr; +} + +export function split(item: Readonly, count: number): PathNode[] { + count = Math.round(count); + if (count <= 1 || !isFinite(count)) { + return [{ ...item }]; + } else if (count === 2) { + return bisect(item); + } else if (count === 3) { + return trisect(item); + } else if (count % 2 === 0) { + const arr = bisect(item); + if (arr.length > 1) { + return split(arr[0], count / 2).concat(split(arr[1], count / 2)); + } + return arr; + } else { + const arr = bisect(item, 1 / count); + if (arr.length > 1) { + return [arr[0], ...split(arr[1], count - 1)]; + } + return arr; + } +} diff --git a/src/app/svg-path/transform.ts b/src/app/svg-path/transform.ts new file mode 100644 index 0000000..876e87c --- /dev/null +++ b/src/app/svg-path/transform.ts @@ -0,0 +1,116 @@ +// tslint:disable: variable-name + +import { ReadonlyMatrix } from '../common/matrix-math'; +import { EllipseShape, isClosePath, isCurveTo, isEllipticalArc, isHLineTo, isQCurveTo, isSmoothCurveTo, isVLineTo } from './command'; +import { getX, getY, PathNode } from './node'; + +export function transformedX(m: ReadonlyMatrix, x: number, y: number) { + return m.a * x + m.c * y + m.e; +} + +export function transformedY(m: ReadonlyMatrix, x: number, y: number) { + return m.b * x + m.d * y + m.f; +} + +export function transformedEllipse(m: ReadonlyMatrix, ellipse: Readonly): EllipseShape { + // Step 1. Rotate ellipse. + // The standard equation for an ellipse: + // x^2 / rx^2 + y^2 / ry^2 = 1 + // It represents an ellipse centered at the origin and with axes lying along the coordinate axes. + + // Applying rotation matrix to it + // | x | = | cos(phi) sin(phi) | * | x0 | + // | y | = |-sin(phi) cos(phi) | | y0 | + // leads to the following equation for a standard ellipse which has been rotated through an angle phi: + // (x * cos(phi) + y * sin(phi))^2 / rx^2 + (-x * sin(phi) + y * cos(phi))^2 / ry^2 = 1 + // Which gives: + // (cos(phi)^2 / rx^2 + sin(phi)^2 / ry^2) * x^2 + // + 2 * cos(phi) * sin(phi) * (1 / rx^2 - 1 / ry^2) * x * y + // + (sin(phi)^2 / rx^2 + cos(phi)^2 / ry^2) * y^2 + // = 1 + // Which is the general conic form: A * x^2 + B * x * y + C * y^2 = 1 + const phi = ellipse.angle * Math.PI / 180; + const sinPhi = Math.sin(phi); + const cosPhi = Math.cos(phi); + const curveX = 1 / (ellipse.rx * ellipse.rx); + const curveY = 1 / (ellipse.ry * ellipse.ry); + // A = cos(phi)^2 / rx^2 + sin(phi)^2 / ry^2 + const A = cosPhi * cosPhi * curveX + sinPhi * sinPhi * curveY; + // B = 2 * cos(phi) * sin(phi) * (1 / rx^2 - 1 / ry^2) + const B = 2 * cosPhi * sinPhi * (curveX - curveY); + // C = sin(phi)^2 / rx^2 + cos(phi)^2 / ry^2 + const C = sinPhi * sinPhi * curveX + cosPhi * cosPhi * curveY; + + // Step 2. Apply ellipse shape transformation matrix. + // | x1 | = | a c | * | x | + // | y1 | = | b d | | y | + // We ignore e and f, since translations don't affect the shape of the ellipse. + // A′*x^2 + B′*x*y + C′*y^2 = D′ + + const A_ = A * m.d * m.d - B * m.b * m.d + C * m.b * m.b; + const B_ = B * (m.a * m.d + m.b * m.c) - 2 * (A * m.c * m.d + C * m.a * m.b); + const C_ = A * m.c * m.c - B * m.a * m.c + C * m.a * m.a; + const D_ = m.a * m.d - m.b * m.c; + + // Step 3. Get back to axis-aligned ellipse equation. + // | x1 | = | cos(phi′) -sin(phi′) | * | x2 | + // | y1 | = | sin(phi′) cos(phi′) | | y2 | + const phi_ = ((Math.atan2(B_, A_ - C_) + Math.PI) % Math.PI) / 2; + // Note: For any integer n, (atan2(B1, A1 - C1) + n*pi)/2 is a solution to the above. + // Incrementing n (rotating an ellipse by pi/2) just swaps the x and y radii computed below. + // Choosing the rotation between 0 and pi/2 eliminates the ambiguity and leads to more predictable output. + + // Finally, we get rx′ and ry′ from the same-zeroes relationship that gave us phi′ + const sinPhi_ = Math.sin(phi_); + const cosPhi_ = Math.cos(phi_); + + const rx_ = Math.abs(D_) / + Math.sqrt(A_ * cosPhi_ * cosPhi_ + B_ * sinPhi_ * cosPhi_ + C_ * sinPhi_ * sinPhi_); + const ry_ = Math.abs(D_) / + Math.sqrt(A_ * sinPhi_ * sinPhi_ - B_ * sinPhi_ * cosPhi_ + C_ * cosPhi_ * cosPhi_); + + const angle_ = phi_ * 180 / Math.PI; + // sweepFlag needs to be inverted for a reflection transformation + const sweepFlag_ = 0 > D_ ? !ellipse.sweepFlag : ellipse.sweepFlag; + + return { rx: rx_ || 0, ry: ry_ || 0, angle: angle_ || 0, largeArcFlag: ellipse.largeArcFlag, sweepFlag: sweepFlag_ }; +} + +export function transformedNode(matrix: ReadonlyMatrix, node: Readonly): PathNode { + if (isClosePath(node)) { + return { name: node.name }; + } else if (isHLineTo(node)) { + const y0 = getY(node.prev); + const x = transformedX(matrix, node.x, y0); + const y = transformedY(matrix, node.x, y0); + return { name: 'L', x, y }; + } else if (isVLineTo(node)) { + const x0 = getX(node.prev); + const x = transformedX(matrix, x0, node.y); + const y = transformedY(matrix, x0, node.y); + return { name: 'L', x, y }; + } else { + const x = transformedX(matrix, node.x, node.y); + const y = transformedY(matrix, node.x, node.y); + if (isQCurveTo(node)) { + const x1 = transformedX(matrix, node.x1, node.y1); + const y1 = transformedY(matrix, node.x1, node.y1); + return { name: node.name, x1, y1, x, y }; + } else if (isSmoothCurveTo(node)) { + const x2 = transformedX(matrix, node.x2, node.y2); + const y2 = transformedY(matrix, node.x2, node.y2); + return { name: node.name, x2, y2, x, y }; + } else if (isCurveTo(node)) { + const x1 = transformedX(matrix, node.x1, node.y1); + const y1 = transformedY(matrix, node.x1, node.y1); + const x2 = transformedX(matrix, node.x2, node.y2); + const y2 = transformedY(matrix, node.x2, node.y2); + return { name: node.name, x1, y1, x2, y2, x, y }; + } else if (isEllipticalArc(node)) { + return { name: node.name, ...transformedEllipse(matrix, node), x, y }; + } else { + // if (isMoveTo(node) || isLineTo(node) || isSmoothQCurveTo(node)) + return { name: node.name, x, y }; + } + } +}