Skip to content

Commit

Permalink
[#1198] add preliminary SVG path parsing to the Path2D class
Browse files Browse the repository at this point in the history
unoptimized and only works for basic shapes for now
  • Loading branch information
obiot committed Dec 27, 2023
1 parent 5e867f5 commit 7f9bfd4
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 4 deletions.
133 changes: 129 additions & 4 deletions src/geometries/path2d.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import pool from "./../system/pooling.js";
import { TAU } from "./../math/math.js";
import earcut from "earcut";

import { endpointToCenterParameterization } from "./toarccanvas.js";
/**
* @classdesc
* a simplified path2d implementation, supporting only one path
*/
export default class Path2D {
constructor() {
constructor(svgPath) {
/**
* the points defining the current path
* @type {Point[]}
Expand All @@ -29,8 +29,75 @@ export default class Path2D {

/* @ignore */
this.isDirty = false;

if (typeof svgPath === "string") {
this.parseSVGPath(svgPath);
}
}

/**
* Parses an SVG path string and adds the points to the current path.
* @param {string} svgPath - The SVG path string to parse.
*/
parseSVGPath(svgPath) {
// Split path into commands and coordinates
const pathCommands = svgPath.match(/([a-df-z])[^a-df-z]*/gi);
const points = this.points;
const startPoint = this.startPoint;
let lastPoint = startPoint;

this.beginPath();

// Process each command and corresponding coordinates
for (let i = 0; i < pathCommands.length; i++) {
const pathCommand = pathCommands[i];
const command = pathCommand[0].toUpperCase();
const coordinates = pathCommand.slice(1).trim().split(/[\s,]+/).map(parseFloat);

switch (command) {
case "A": {
// A command takes 5 coordinates
const p = endpointToCenterParameterization(...coordinates);
this.arc(p.x, p.y, p.radiusX, p.radiusY, p.rotation, p.startAngle, p.endAngle, p.applyanticlockwise);
}
break;
case "H":
// H take 1 coordinate
lastPoint = points.length === 0 ? startPoint : points[points.length-1];
this.lineTo(lastPoint.x + coordinates[0], lastPoint.y);
break;
case "V":
// V take 1 coordinate
lastPoint = points.length === 0 ? startPoint : points[points.length-1];
this.lineTo(lastPoint.x, lastPoint.y + coordinates[0]);
break;
case "M":
// M takes 2 coordinates
this.moveTo(...coordinates);
break;
case "L":
// L takes 2 coordinates
this.lineTo(...coordinates);
break;
case "Q":
// Q takes 4 coordinates
this.quadraticCurveTo(...coordinates);
break;
case "C":
// C takes 6 coordinates
this.bezierCurveTo(...coordinates);
break;
case "Z":
this.closePath();
break;
default:
console.warn("Unsupported command:", command);
break;
}
}
}


/**
* begin a new path
*/
Expand Down Expand Up @@ -190,9 +257,11 @@ export default class Path2D {
*/
arcTo(x1, y1, x2, y2, radius) {
let points = this.points;
// based on from https://github.com/karellodewijk/canvas-webgl/blob/master/canvas-webgl.js
let x0 = points[points.length-1].x, y0 = points[points.length-1].y;
let startPoint = this.startPoint;
let lastPoint = points.length === 0 ? startPoint : points[points.length-1];

// based on from https://github.com/karellodewijk/canvas-webgl/blob/master/canvas-webgl.js
let x0 = lastPoint.x, y0 = lastPoint.y;
//a = -incoming vector, b = outgoing vector to x1, y1
let a0 = x0 - x1, a1 = y0 - y1;
let b0 = x2 - x1, b1 = y2 - y1;
Expand Down Expand Up @@ -286,6 +355,62 @@ export default class Path2D {
this.isDirty = true;
}

/**
* Adds a quadratic Bézier curve to the path.
* @param {number} cpX - The x-coordinate of the control point.
* @param {number} cpY - The y-coordinate of the control point.
* @param {number} x - The x-coordinate of the end point of the curve.
* @param {number} y - The y-coordinate of the end point of the curve.
*/
quadraticCurveTo(cpX, cpY, x, y) {
const points = this.points;
const startPoint = this.startPoint;
const lastPoint = points.length === 0 ? startPoint : points[points.length-1];
const endPoint = pool.pull("Point").set(x, y);
const controlPoint = pool.pull("Point").set(cpX, cpY);
const resolution = this.arcResolution;

const t = 1 / resolution;
for (let i = 1; i <= resolution; i++) {
this.lineTo(
lastPoint.x * Math.pow(1 - t * i, 2) + controlPoint.x * 2 * (1 - t * i) * t * i + endPoint.x * Math.pow(t * i, 2),
lastPoint.y * Math.pow(1 - t * i, 2) + controlPoint.y * 2 * (1 - t * i) * t * i + endPoint.y * Math.pow(t * i, 2)
);
}
pool.push(endPoint, controlPoint);
this.isDirty = true;
}

/**
* Adds a cubic Bézier curve to the path.
* @param {number} cp1X - The x-coordinate of the first control point.
* @param {number} cp1Y - The y-coordinate of the first control point.
* @param {number} cp2X - The x-coordinate of the second control point.
* @param {number} cp2Y - The y-coordinate of the second control point.
* @param {number} x - The x-coordinate of the end point of the curve.
* @param {number} y - The y-coordinate of the end point of the curve.
*/
bezierCurveTo(cp1X, cp1Y, cp2X, cp2Y, x, y) {
const points = this.points;
const startPoint = this.startPoint;
const lastPoint = points.length === 0 ? startPoint : points[points.length-1];
const endPoint = pool.pull("Point").set(x, y);
const controlPoint1 = pool.pull("Point").set(cp1X, cp1Y);
const controlPoint2 = pool.pull("Point").set(cp2X, cp2Y);
const resolution = this.arcResolution;

const t = 1 / resolution;
for (let i = 1; i <= resolution; i++) {
this.lineTo(
lastPoint.x * Math.pow(1 - t * i, 3) + controlPoint1.x * 3 * Math.pow(1 - t * i, 2) * t * i + controlPoint2.x * 3 * (1 - t * i) * Math.pow(t * i, 2) + endPoint.x * Math.pow(t * i, 3),
lastPoint.y * Math.pow(1 - t * i, 3) + controlPoint1.y * 3 * Math.pow(1 - t * i, 2) * t * i + controlPoint2.y * 3 * (1 - t * i) * Math.pow(t * i, 2) + endPoint.y * Math.pow(t * i, 3)
);
}

pool.push(endPoint, controlPoint1, controlPoint2);
this.isDirty = true;
}

/**
* creates a path for a rectangle at position (x, y) with a size that is determined by width and height.
* @param {number} x - the x-axis coordinate of the rectangle's starting point.
Expand Down
94 changes: 94 additions & 0 deletions src/geometries/toarccanvas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { degToRad, pow } from "./../math/math.js";

function correctRadii(signedRx, signedRy, x1p, y1p) {
const prx = Math.abs(signedRx);
const pry = Math.abs(signedRy);

const A = pow(x1p) / pow(prx) + pow(y1p) / pow(pry);

const rx = A > 1 ? Math.sqrt(A) * prx : prx;
const ry = A > 1 ? Math.sqrt(A) * pry : pry;

return [rx, ry];
}

function mat2DotVec2([m00, m01, m10, m11], [vx, vy]) {
return [m00 * vx + m01 * vy, m10 * vx + m11 * vy];
}

function vec2Add([ux, uy], [vx, vy]) {
return [ux + vx, uy + vy];
}

function vec2Scale([a0, a1], scalar) {
return [a0 * scalar, a1 * scalar];
}

function vec2Dot([ux, uy], [vx, vy]) {
return ux * vx + uy * vy;
}

function vec2Mag([ux, uy]) {
return Math.sqrt(ux ** 2 + uy ** 2);
}

function vec2Angle(u, v) {
const [ux, uy] = u;
const [vx, vy] = v;
const sign = ux * vy - uy * vx >= 0 ? 1 : -1;
return sign * Math.acos(vec2Dot(u, v) / (vec2Mag(u) * vec2Mag(v)));
}

// From https://svgwg.org/svg2-draft/implnote.html#ArcConversionEndpointToCenter
export function endpointToCenterParameterization(x1, y1, x2, y2, largeArcFlag, sweepFlag, srx, sry, xAxisRotationDeg) {
const xAxisRotation = degToRad(xAxisRotationDeg);

const cosphi = Math.cos(xAxisRotation);
const sinphi = Math.sin(xAxisRotation);

const [x1p, y1p] = mat2DotVec2(
[cosphi, sinphi, -sinphi, cosphi],
[(x1 - x2) / 2, (y1 - y2) / 2]
);

const [rx, ry] = correctRadii(srx, sry, x1p, y1p);

const sign = largeArcFlag !== sweepFlag ? 1 : -1;
const n = pow(rx) * pow(ry) - pow(rx) * pow(y1p) - pow(ry) * pow(x1p);
const d = pow(rx) * pow(y1p) + pow(ry) * pow(x1p);

const [cxp, cyp] = vec2Scale(
[(rx * y1p) / ry, (-ry * x1p) / rx],
sign * Math.sqrt(Math.abs(n / d))
);

const [cx, cy] = vec2Add(
mat2DotVec2([cosphi, -sinphi, sinphi, cosphi], [cxp, cyp]),
[(x1 + x2) / 2, (y1 + y2) / 2]
);

const a = [(x1p - cxp) / rx, (y1p - cyp) / ry];
const b = [(-x1p - cxp) / rx, (-y1p - cyp) / ry];
const startAngle = vec2Angle([1, 0], a);
const deltaAngle0 = vec2Angle(a, b) % (2 * Math.PI);

const deltaAngle =
!sweepFlag && deltaAngle0 > 0
? deltaAngle0 - 2 * Math.PI
: sweepFlag && deltaAngle0 < 0
? deltaAngle0 + 2 * Math.PI
: deltaAngle0;

const endAngle = startAngle + deltaAngle;

return {
cx,
cy,
rx,
ry,
startAngle,
endAngle,
xAxisRotation,
anticlockwise: deltaAngle < 0
};
}
10 changes: 10 additions & 0 deletions src/math/math.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,13 @@ export function round(num, dec = 0) {
export function toBeCloseTo(expected, actual, precision = 2) {
return Math.abs(expected - actual) < (Math.pow(10, -precision) / 2);
}


/**
* Calculates the power of a number.
* @param {number} n - The number to be raised to the power of 2.
* @returns {number} The result of raising the number to the power of 2.
*/
export function pow(n) {
return Math.pow(n, 2);
}

0 comments on commit 7f9bfd4

Please # to comment.