javascriptsvgdata-conversiondxf

Converting an SVG path arc to DXF


I am working on creating a custom converter from svg to dxf and need a math wizard to help me figure out where my logic is flawed.

Here is my current code:

function svgArcToLWPolyline(rx, ry, rotation, largeArcFlag, sweepFlag, x1, y1, x2, y2) {
    const scaleX = Math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)) / (2 * rx);
    const scaleY = Math.sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2)) / (2 * ry);

    const arcInfo = svgArcToOvalArc(rx * scaleX, ry * scaleY, rotation, largeArcFlag, sweepFlag, x1, y1, x2, y2);
    const numPoints = calculateNumberOfPoints(rx * scaleX, ry * scaleY, arcInfo.startAngle, arcInfo.endAngle);

    // Calculate bulge factors
    const bulgeFactors = [];
    const angleIncrement = (arcInfo.endAngle - arcInfo.startAngle) / (numPoints - 1);
    let currentAngle = arcInfo.startAngle;
    for (let i = 0; i < numPoints - 1; i++) {
        const nextAngle = currentAngle + angleIncrement;
        const bulge = Math.tan((nextAngle - currentAngle) * Math.PI / 360);
        bulgeFactors.push(bulge);
        currentAngle = nextAngle;
    }
    bulgeFactors.push(0); // Last point has zero bulge

    // Construct LWPOLYLINE points
    const lwpolylinePoints = [];
    for (let i = 0; i < numPoints; i++) {
        const angle = arcInfo.startAngle + i * angleIncrement;
        const x = arcInfo.cx + rx * scaleX * Math.cos(angle * Math.PI / 180);
        const y = arcInfo.cy + ry * scaleY * Math.sin(angle * Math.PI / 180);
        lwpolylinePoints.push([x, y, bulgeFactors[i]]);
    }

    return lwpolylinePoints;
}

function svgArcToOvalArc(rx, ry, rotation, largeArcFlag, sweepFlag, x1, y1, x2, y2) {
    // Convert rotation angle to radians
    const angle = rotation * Math.PI / 180;

    // Calculate intermediate values
    const dx = (x1 - x2) / 2;
    const dy = (y1 - y2) / 2;
    const x1p = Math.cos(angle) * dx + Math.sin(angle) * dy;
    const y1p = -Math.sin(angle) * dx + Math.cos(angle) * dy;
    const rxSq = rx * rx;
    const rySq = ry * ry;
    const x1pSq = x1p * x1p;
    const y1pSq = y1p * y1p;
    let radicand = (rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / (rxSq * y1pSq + rySq * x1pSq);

    // Ensure non-negative radicand
    if (radicand < 0) {
        radicand = 0;
    }

    // Calculate root
    let root = Math.sqrt(radicand);
    if (largeArcFlag === sweepFlag) {
        root = -root;
    }
    const cxp = root * rx * y1p / ry;
    const cyp = -root * ry * x1p / rx;

    // Calculate center
    const cx = Math.cos(angle) * cxp - Math.sin(angle) * cyp + (x1 + x2) / 2;
    const cy = Math.sin(angle) * cxp + Math.cos(angle) * cyp + (y1 + y2) / 2;

    // Calculate start and end angles
    let startAngle = Math.atan2((y1p - cyp) / ry, (x1p - cxp) / rx);
    let endAngle = Math.atan2((-y1p - cyp) / ry, (-x1p - cxp) / rx);

    // Convert angles to degrees
    startAngle *= 180 / Math.PI;
    endAngle *= 180 / Math.PI;

    // Adjust angles to be in the range [0, 360]
    if (startAngle < 0) {
        startAngle += 360;
    }
    if (endAngle < 0) {
        endAngle += 360;
    }

    return { cx: cx, cy: cy, startAngle: startAngle, endAngle: endAngle };
}

function calculateNumberOfPoints(rx, ry, startAngle, endAngle) {
    // Calculate arc length
    let arcLength;
    if (startAngle <= endAngle) {
        arcLength = (endAngle - startAngle) * Math.PI / 180;
    } else {
        arcLength = (360 - startAngle + endAngle) * Math.PI / 180;
    }
    arcLength *= (rx + ry) / 2;

    // Choose a fixed length for each segment
    const segmentLength = 1.0;  // Adjust as needed

    // Calculate the number of points
    const numPoints = Math.max(Math.floor(arcLength / segmentLength), 2);  // Minimum of 2 points

    return numPoints;
}

Here is what the svg looks like using
d="M10,10L20,25A1,3,0,0,0,50,50L90,90V80Z"

SVG:

svg

DXF:
dxf

As you can see, the ry and rx aren't being taken into account. And when I change my path to add a xRotationalAxis, my dxf breaks even more: d="M10,10L20,25A1,3,45,0,0,50,50L90,90V80Z":

svg with path M10,10L20,25A1,3,45,0,0,50,50L90,90V80Z
svg

Dxf conversion of M10,10L20,25A1,3,45,0,0,50,50L90,90V80Z

dxf

I've spent 12 hours trying to tweek this and figure out mathmatically how to make it work (with a lot of help from ChatGPT) So any help I can get on this would be really nice!


Solution

  • I'm afraid there are multiple errors in the calculation helper.
    Most importantly you need to calculate the actual rx and ry values via parametrisation.

    In other words A 1,3, 45,0,0,50,50 doesn't contain the absolute values for rx/ry – rx=1 and ry=3 are relative values.
    Here's an example based on cuixiping's answer "Calculate center of SVG arc"

    // arc command values
    let p0 = {
      x: 20,
      y: 25
    }
    let values = [1, 3, 45, 0, 0, 50, 50]
    let vertices = 12;
    
    
    let polylinePts = arcToPolyline(p0, values, vertices)
    //render polyline
    polyline.setAttribute('points', polylinePts.map(pt => {
      return `${pt.x} ${pt.y}`
    }))
    
    
    function arcToPolyline(p0, values, vertices = 12) {
      let [rxC, ryC, xAxisRotation, largeArc, sweep, x, y] = values;
    
      // parametrize arc command to get actual rx,ry and center
      let param = svgArcToCenterParam(p0.x, p0.y, rxC, ryC, xAxisRotation, largeArc, sweep, x, y);
      let {
        cx,
        cy,
        rx,
        ry,
        startAngle,
        deltaAngle,
        endAngle
      } = param;
      let splitAngle = deltaAngle / vertices;
    
      let pts = []
      for (let i = 0; i <= vertices; i++) {
        let angle = startAngle - splitAngle * i;
        let xAxisRotation_radian = xAxisRotation * Math.PI / 180;
        let pt = getEllipsePointForAngle(cx, cy, rx, ry, xAxisRotation_radian, deltaAngle + angle)
        pts.push(pt)
      }
      return pts;
    }
    
    
    /**
     * based on @cuixiping;
     * https://stackoverflow.com/questions/9017100/calculate-center-of-svg-arc/12329083#12329083
     */
    function svgArcToCenterParam(p0x, p0y, rx, ry, angle, largeArc, sweep, px, py) {
      const radian = (ux, uy, vx, vy) => {
        let dot = ux * vx + uy * vy;
        let mod = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy));
        let rad = Math.acos(dot / mod);
        if (ux * vy - uy * vx < 0) {
          rad = -rad;
        }
        return rad;
      };
      // degree to radian – if rx equals ry the x-axis rotation has no effect
      let phi = rx === ry ? 0 : (angle * Math.PI) / 180;
      let cx, cy, startAngle, deltaAngle, endAngle;
      let PI = Math.PI;
      //let PIpx = PI * 2;
    
      // invalid arguments
      if (rx == 0 || ry == 0) {
        throw Error("rx and ry can not be 0");
      }
      if (rx < 0) {
        rx = -rx;
      }
      if (ry < 0) {
        ry = -ry;
      }
      let s_phi = Math.sin(phi);
      let c_phi = Math.cos(phi);
      let hd_x = (p0x - px) / 2; // half diff of x
      let hd_y = (p0y - py) / 2; // half diff of y
      let hs_x = (p0x + px) / 2; // half sum of x
      let hs_y = (p0y + py) / 2; // half sum of y
      // F6.5.1
      let p0x_ = c_phi * hd_x + s_phi * hd_y;
      let p0y_ = c_phi * hd_y - s_phi * hd_x;
      // F.6.6 Correction of out-of-range radii
      //   Step 3: Ensure radii are large enough
      let lambda = (p0x_ * p0x_) / (rx * rx) + (p0y_ * p0y_) / (ry * ry);
      if (lambda > 1) {
        rx = rx * Math.sqrt(lambda);
        ry = ry * Math.sqrt(lambda);
      }
      let rxry = rx * ry;
      let rxp0y_ = rx * p0y_;
      let ryp0x_ = ry * p0x_;
      let sum_of_sq = rxp0y_ * rxp0y_ + ryp0x_ * ryp0x_; // sum of square
      if (!sum_of_sq) {
        console.log("start point can not be same as end point");
      }
      let coe = Math.sqrt(Math.abs((rxry * rxry - sum_of_sq) / sum_of_sq));
      if (largeArc == sweep) {
        coe = -coe;
      }
      // F6.5.2
      let cx_ = (coe * rxp0y_) / ry;
      let cy_ = (-coe * ryp0x_) / rx;
      // F6.5.3
      cx = c_phi * cx_ - s_phi * cy_ + hs_x;
      cy = s_phi * cx_ + c_phi * cy_ + hs_y;
      let xcr1 = (p0x_ - cx_) / rx;
      let xcr2 = (p0x_ + cx_) / rx;
      let ycr1 = (p0y_ - cy_) / ry;
      let ycr2 = (p0y_ + cy_) / ry;
      // F6.5.5
      startAngle = radian(1, 0, xcr1, ycr1);
      // F6.5.6
      deltaAngle = radian(xcr1, ycr1, -xcr2, -ycr2);
      if (deltaAngle > PI * 2) {
        deltaAngle -= PI * 2;
      } else if (deltaAngle < 0) {
        deltaAngle += PI * 2;
      }
      if (sweep == false || sweep == 0) {
        deltaAngle -= PI * 2;
      }
      endAngle = startAngle + deltaAngle;
      if (endAngle > PI * 2) {
        endAngle -= PI * 2;
      } else if (endAngle < 0) {
        endAngle += PI * 2;
      }
      let outputObj = {
        cx: cx,
        cy: cy,
        rx: rx,
        ry: ry,
        startAngle: startAngle,
        deltaAngle: deltaAngle,
        endAngle: endAngle,
        sweep: sweep
      };
      return outputObj;
    }
    
    
    
    /*
     * based on
     * https://source.chromium.org/chromium/chromium/src/+/main:third_party/skia/modules/canvaskit/htmlcanvas/path2d.js
     * and https://observablehq.com/@toja/ellipse-and-elliptical-arc-conversion
     */
    
    function getEllipsePointForAngle(cx, cy, rx, ry, rotation_angle, angle) {
      let M = Math.abs(rx) * Math.cos(angle),
        N = Math.abs(ry) * Math.sin(angle);
      return {
        x: cx + Math.cos(rotation_angle) * M - Math.sin(rotation_angle) * N,
        y: cy + Math.sin(rotation_angle) * M + Math.cos(rotation_angle) * N
      };
    }
    svg {
      overflow: visible;
      border: 1px solid #ccc;
    }
    <svg id="svg" viewBox="-9 0 100 100">
      <path id="path" d="M 10 10
              L 20 25
              A 1 3 45 0 0 50 50
              L 90 90
              V 80 
              Z" fill="none" stroke="#000" />
      <polyline id="polyline" fill="none" stroke="red"></polyline>
    </svg>

    How it works

    1. svgArcToCenterParam(p0x, p0y, rx, ry, angle, largeArc, sweep, px, py) returns the parametrized ellipse parameters
    2. now we can calculate multiple points on the ellipse via getEllipsePointForAngle(cx, cy, rx, ry, rotation_angle, angle)

    Maker.js

    For a more convenient conversion you may just use maker.js as it includes an dxf export method out-of-the-box

    var m = require("makerjs");
    
    var pathData = `M10,10L20,25 A1,3,0,0,0,50,50L90,90V80Z`;
    var path =  m.importer.fromSVGPathData(pathData)
    
    // convert to maker.js model object
    var model = {models: {path}}
    
    //export
    var dxf = m.exporter.toDXF(model)
    outputDxf.value=dxf;
    textarea{
      display:block;
      width:100%;
      min-height:50vmin
    }
    <script src="https://cdn.jsdelivr.net/npm/makerjs@0/target/js/browser.maker.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/bezier-js@2/bezier.js"></script>
    
    
    <h3>DXF</h3>
    <textarea id="outputDxf"></textarea>

    You may also take inspiration from my path-to-polygon helper (or simple example). Its based on a custom path data parser and calculates lengths for each path segment. This way we can calculate polygon vertices respecting the command on-path-points.