diff --git a/src/geometries/path2d.js b/src/geometries/path2d.js index b10bc8f4e..6d26a38ed 100755 --- a/src/geometries/path2d.js +++ b/src/geometries/path2d.js @@ -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[]} @@ -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 */ @@ -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; @@ -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. diff --git a/src/geometries/toarccanvas.js b/src/geometries/toarccanvas.js new file mode 100644 index 000000000..879da2b1d --- /dev/null +++ b/src/geometries/toarccanvas.js @@ -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 + }; +} diff --git a/src/math/math.js b/src/math/math.js index 5b95b1f76..17eb06bc8 100755 --- a/src/math/math.js +++ b/src/math/math.js @@ -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); +}