Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

SVG smooth freehand drawing

Tags:

javascript

svg

I implemented a freehand drawing of a path using native JS. But as expected path edges are little aggressive and not smooth. So I have an option of using simplifyJS to simplify points and then redraw path. But like here, instead of smoothening after drawing, I am trying to find simplified edges while drawing

Here is my code:

    var x0, y0;

    var dragstart = function(event) {
        var that = this;
        var pos = coordinates(event);
        x0 = pos.x;
        y0 = pos.y;
        that.points = [];
    };

    var dragging = function(event) {
        var that = this;
        var xy = coordinates(event);
        var points = that.points;
        var x1 = xy.x, y1 = xy.y, dx = x1 - x0, dy = y1 - y0;
        if (dx * dx + dy * dy > 100) {
            xy = {
                x: x0 = x1, 
                y: y0 = y1
            };
        } else {
            xy = {
                x: x1, 
                y: y1
            };
        }
        points.push(xy);
    };

But it is not working as in the link added above. Still edges are not good. Please help.

enter image description here enter image description here

like image 211
Exception Avatar asked Oct 29 '16 22:10

Exception


2 Answers

The following code snippet makes the curve smoother by calculating the average of the last mouse positions. The level of smoothing depends on the size of the buffer in which these values are kept. You can experiment with the different buffer sizes offered in the dropdown list. The behavior with a 12 point buffer is somewhat similar to the Mike Bostock's code snippet that you refer to in the question.

More sophisticated techniques could be implemented to get the smoothed point from the positions stored in the buffer (weighted average, linear regression, cubic spline smoothing, etc.) but this simple average method may be sufficiently accurate for your needs.

var strokeWidth = 2;
var bufferSize;

var svgElement = document.getElementById("svgElement");
var rect = svgElement.getBoundingClientRect();
var path = null;
var strPath;
var buffer = []; // Contains the last positions of the mouse cursor

svgElement.addEventListener("mousedown", function (e) {
    bufferSize = document.getElementById("cmbBufferSize").value;
    path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    path.setAttribute("fill", "none");
    path.setAttribute("stroke", "#000");
    path.setAttribute("stroke-width", strokeWidth);
    buffer = [];
    var pt = getMousePosition(e);
    appendToBuffer(pt);
    strPath = "M" + pt.x + " " + pt.y;
    path.setAttribute("d", strPath);
    svgElement.appendChild(path);
});

svgElement.addEventListener("mousemove", function (e) {
    if (path) {
        appendToBuffer(getMousePosition(e));
        updateSvgPath();
    }
});

svgElement.addEventListener("mouseup", function () {
    if (path) {
        path = null;
    }
});

var getMousePosition = function (e) {
    return {
        x: e.pageX - rect.left,
        y: e.pageY - rect.top
    }
};

var appendToBuffer = function (pt) {
    buffer.push(pt);
    while (buffer.length > bufferSize) {
        buffer.shift();
    }
};

// Calculate the average point, starting at offset in the buffer
var getAveragePoint = function (offset) {
    var len = buffer.length;
    if (len % 2 === 1 || len >= bufferSize) {
        var totalX = 0;
        var totalY = 0;
        var pt, i;
        var count = 0;
        for (i = offset; i < len; i++) {
            count++;
            pt = buffer[i];
            totalX += pt.x;
            totalY += pt.y;
        }
        return {
            x: totalX / count,
            y: totalY / count
        }
    }
    return null;
};

var updateSvgPath = function () {
    var pt = getAveragePoint(0);

    if (pt) {
        // Get the smoothed part of the path that will not change
        strPath += " L" + pt.x + " " + pt.y;

        // Get the last part of the path (close to the current mouse position)
        // This part will change if the mouse moves again
        var tmpPath = "";
        for (var offset = 2; offset < buffer.length; offset += 2) {
            pt = getAveragePoint(offset);
            tmpPath += " L" + pt.x + " " + pt.y;
        }

        // Set the complete current path coordinates
        path.setAttribute("d", strPath + tmpPath);
    }
};
html, body
{
    padding: 0px;
    margin: 0px;
}
#svgElement
{
    border: 1px solid;
    margin-top: 4px;
    margin-left: 4px;
    cursor: default;
}
#divSmoothingFactor
{
    position: absolute;
    left: 14px;
    top: 12px;
}
<div id="divSmoothingFactor">
    <label for="cmbBufferSize">Buffer size:</label>
    <select id="cmbBufferSize">
        <option value="1">1 - No smoothing</option>
        <option value="4">4 - Sharp curves</option>
        <option value="8" selected="selected">8 - Smooth curves</option>
        <option value="12">12 - Very smooth curves</option>
        <option value="16">16 - Super smooth curves</option>
        <option value="20">20 - Hyper smooth curves</option>
    </select>
</div>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="svgElement" x="0px" y="0px" width="600px" height="400px" viewBox="0 0 600 400" enable-background="new 0 0 600 400" xml:space="preserve">
like image 123
ConnorsFan Avatar answered Nov 16 '22 02:11

ConnorsFan


Quadtratic Bézier polyline smoothing

@ConnorsFan solution works great and is probably providing a better rendering performance and more responsive drawing experience.
In case you need a more compact svg output (in terms of markup size) quadratic smoothing might be interesting.
E.g. if you need to export the drawings in an efficient way.

Simplified example: polyline smoothing

quadratic bezier polyline smoothing

Green dots show the original polyline coordinates (in x/y pairs).
Purple points represent interpolated middle coordinates – simply calculated like so:
[(x1+x2)/2, (y1+y2)/2].

The original coordinates (highlighted green) become quadratic bézier control points
whereas the interpolated middle points will be the end points.

let points = [{
    x: 0,
    y: 10
  },
  {
    x: 10,
    y: 20
  },
  {
    x: 20,
    y: 10
  },
  {
    x: 30,
    y: 20
  },
  {
    x: 40,
    y: 10
  }
];

path.setAttribute("d", smoothQuadratic(points));

function smoothQuadratic(points) {
  // set M/starting point
  let [Mx, My] = [points[0].x, points[0].y];
  let d = `M ${Mx} ${My}`;
  renderPoint(svg, [Mx, My], "green", "1");

  // split 1st line segment
  let [x1, y1] = [points[1].x, points[1].y];
  let [xM, yM] = [(Mx + x1) / 2, (My + y1) / 2];
  d += `L ${xM} ${yM}`;
  renderPoint(svg, [xM, yM], "purple", "1");

  for (let i = 1; i < points.length; i += 1) {
    let [x, y] = [points[i].x, points[i].y];
    // calculate mid point between current and next coordinate
    let [xN, yN] = points[i + 1] ? [points[i + 1].x, points[i + 1].y] : [x, y];
    let [xM, yM] = [(x + xN) / 2, (y + yN) / 2];

    // add quadratic curve:
    d += `Q${x} ${y} ${xM} ${yM}`;
    renderPoint(svg, [xM, yM], "purple", "1");
    renderPoint(svg, [x, y], "green", "1");
  }
  return d;
}

pathRel.setAttribute("d", smoothQuadraticRelative(points));


function smoothQuadraticRelative(points, skip = 0, decimals = 3) {
  let pointsL = points.length;
  let even = pointsL - skip - (1 % 2) === 0;

  // set M/starting point
  let type = "M";
  let values = [points[0].x, points[0].y];
  let [Mx, My] = values.map((val) => {
    return +val.toFixed(decimals);
  });
  let dRel = `${type}${Mx} ${My}`;
  // offsets for relative commands
  let xO = Mx;
  let yO = My;

  // split 1st line segment
  let [x1, y1] = [points[1].x, points[1].y];
  let [xM, yM] = [(Mx + x1) / 2, (My + y1) / 2];
  let [xMR, yMR] = [xM - xO, yM - yO].map((val) => {
    return +val.toFixed(decimals);
  });
  dRel += `l${xMR} ${yMR}`;
  xO += xMR;
  yO += yMR;

  for (let i = 1; i < points.length; i += 1 + skip) {
    // control point
    let [x, y] = [points[i].x, points[i].y];
    let [xR, yR] = [x - xO, y - yO];

    // next point
    let [xN, yN] = points[i + 1 + skip] ?
      [points[i + 1 + skip].x, points[i + 1 + skip].y] :
      [points[pointsL - 1].x, points[pointsL - 1].y];
    let [xNR, yNR] = [xN - xO, yN - yO];

    // mid point
    let [xM, yM] = [(x + xN) / 2, (y + yN) / 2];
    let [xMR, yMR] = [(xR + xNR) / 2, (yR + yNR) / 2];

    type = "q";
    values = [xR, yR, xMR, yMR];
    // switch to t command
    if (i > 1) {
      type = "t";
      values = [xMR, yMR];
    }
    dRel += `${type}${values
      .map((val) => {
        return +val.toFixed(decimals);
      })
      .join(" ")} `;
    xO += xMR;
    yO += yMR;
  }

  // add last line if odd number of segments
  if (!even) {
    values = [points[pointsL - 1].x - xO, points[pointsL - 1].y - yO];
    dRel += `l${values
      .map((val) => {
        return +val.toFixed(decimals);
      })
      .join(" ")}`;
  }
  return dRel;
}

function renderPoint(svg, coords, fill = "red", r = "2") {
  let marker =
    '<circle cx="' +
    coords[0] +
    '" cy="' +
    coords[1] +
    '"  r="' +
    r +
    '" fill="' +
    fill +
    '" ><title>' +
    coords.join(", ") +
    "</title></circle>";
  svg.insertAdjacentHTML("beforeend", marker);
}
svg {
  border: 1px solid #ccc;
  width: 45vw;
  overflow: visible;
  margin-right: 1vw;
}

path {
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-opacity: 0.5;
}
<svg id="svg" viewBox="0 0 40 30">
  <path d="M 0 10 L 10 20  20 10 L 30 20 40 10" fill="none" stroke="#999" stroke-width="1"></path>
  <path id="path" d="" fill="none" stroke="red" stroke-width="1" />
</svg>

<svg id="svg2" viewBox="0 0 40 30">
  <path d="M 0 10 L 10 20  20 10 L 30 20 40 10" fill="none" stroke="#999" stroke-width="1"></path>
  <path id="pathRel" d="" fill="none" stroke="red" stroke-width="1" />
</svg>

Example: Svg draw Pad

const svg = document.getElementById("svg");
const svgns = "http://www.w3.org/2000/svg";
let strokeWidth = 0.25;
// rounding and smoothing
let decimals = 2;

let getNthMouseCoord = 1;
let smooth = 2;

// init
let isDrawing = false;
var points = [];
let path = "";
let pointCount = 0;

const drawStart = (e) => {
  pointCount = 0;
  isDrawing = true;
  // create new path
  path = document.createElementNS(svgns, "path");
  svg.appendChild(path);
};

const draw = (e) => {
  if (isDrawing) {
    pointCount++;
    if (getNthMouseCoord && pointCount % getNthMouseCoord === 0) {
      let point = getMouseOrTouchPos(e);
      // save to point array
      points.push(point);
    }
    if (points.length > 1) {
      let d = smoothQuadratic(points, smooth, decimals);
      path.setAttribute("d", d);
    }
  }
};

const drawEnd = (e) => {
  isDrawing = false;
  points = [];
  // just illustrating the ouput
  svgMarkup.value = svg.outerHTML;
};

// start drawing: create new path;
svg.addEventListener("mousedown", drawStart);
svg.addEventListener("touchstart", drawStart);
svg.addEventListener("mousemove", draw);
svg.addEventListener("touchmove", draw);

// stop drawing, reset point array for next line
svg.addEventListener("mouseup", drawEnd);
svg.addEventListener("touchend", drawEnd);
svg.addEventListener("touchcancel", drawEnd);

function smoothQuadratic(points, skip = 0, decimals = 3) {
  let pointsL = points.length;
  let even = pointsL - skip - (1 % 2) === 0;

  // set M/starting point
  let type = "M";
  let values = [points[0].x, points[0].y];
  let [Mx, My] = values.map((val) => {
    return +val.toFixed(decimals);
  });
  let dRel = `${type}${Mx} ${My}`;
  // offsets for relative commands
  let xO = Mx;
  let yO = My;

  // split 1st line segment
  let [x1, y1] = [points[1].x, points[1].y];
  let [xM, yM] = [(Mx + x1) / 2, (My + y1) / 2];
  let [xMR, yMR] = [xM - xO, yM - yO].map((val) => {
    return +val.toFixed(decimals);
  });
  dRel += `l${xMR} ${yMR}`;
  xO += xMR;
  yO += yMR;

  for (let i = 1; i < points.length; i += 1 + skip) {
    // control point
    let [x, y] = [points[i].x, points[i].y];
    let [xR, yR] = [x - xO, y - yO];

    // next point
    let [xN, yN] = points[i + 1 + skip] ?
      [points[i + 1 + skip].x, points[i + 1 + skip].y] :
      [points[pointsL - 1].x, points[pointsL - 1].y];
    let [xNR, yNR] = [xN - xO, yN - yO];

    // mid point
    let [xM, yM] = [(x + xN) / 2, (y + yN) / 2];
    let [xMR, yMR] = [(xR + xNR) / 2, (yR + yNR) / 2];

    type = "q";
    values = [xR, yR, xMR, yMR];
    // switch to t command
    if (i > 1) {
      type = "t";
      values = [xMR, yMR];
    }
    dRel += `${type}${values
      .map((val) => {
        return +val.toFixed(decimals);
      })
      .join(" ")} `;
    xO += xMR;
    yO += yMR;
  }

  // add last line if odd number of segments
  if (!even) {
    values = [points[pointsL - 1].x - xO, points[pointsL - 1].y - yO];
    dRel += `l${values
      .map((val) => {
        return +val.toFixed(decimals);
      })
      .join(" ")}`;
  }
  return dRel;
}

/**
 * based on:
 * @Daniel Lavedonio de Lima
 * https://stackoverflow.com/a/61732450/3355076
 */
function getMouseOrTouchPos(e) {
  let x, y;
  // touch cooordinates
  if (
    e.type == "touchstart" ||
    e.type == "touchmove" ||
    e.type == "touchend" ||
    e.type == "touchcancel"
  ) {
    let evt = typeof e.originalEvent === "undefined" ? e : e.originalEvent;
    let touch = evt.touches[0] || evt.changedTouches[0];
    x = touch.pageX;
    y = touch.pageY;
  } else if (
    e.type == "mousedown" ||
    e.type == "mouseup" ||
    e.type == "mousemove" ||
    e.type == "mouseover" ||
    e.type == "mouseout" ||
    e.type == "mouseenter" ||
    e.type == "mouseleave"
  ) {
    x = e.clientX;
    y = e.clientY;
  }

  // get svg user space coordinates
  let point = svg.createSVGPoint();
  point.x = x;
  point.y = y;
  let ctm = svg.getScreenCTM().inverse();
  point = point.matrixTransform(ctm);
  return point;
}
body {
  margin: 0;
  font-family: sans-serif;
  padding: 1em;
}

* {
  box-sizing: border-box;
}

svg {
  width: 100%;
  max-height: 75vh;
  overflow: visible;
}

textarea {
  width: 100%;
  min-height: 50vh;
  resize: none;
}

.border {
  border: 1px solid #ccc;
}

path {
  fill: none;
  stroke: #000;
  stroke-linecap: round;
  stroke-linejoin: round;
}

input[type="number"] {
  width: 3em;
}

input[type="number"]::-webkit-inner-spin-button {
  opacity: 1;
}

@media (min-width: 720px) {
  svg {
    width: 75%;
  }
  textarea {
    width: 25%;
  }
  .flex {
    display: flex;
    gap: 1em;
  }
  .flex * {
    flex: 1 0 auto;
  }
}
<h2>Draw quadratic bezier (relative commands)</h2>
<p><button type="button" id="clear" onclick="clearDrawing()">Clear</button>
  <label>Get nth Mouse position</label><input type="number" id="nthMouseCoord" value="1" min="0" oninput="changeVal()">
  <label>Smooth</label><input type="number" id="simplifyDrawing" min="0" value="2" oninput="changeVal()">
</p>
<div class="flex">
  <svg class="border" id="svg" viewBox="0 0 200 100">
</svg>
  <textarea class="border" id="svgMarkup"></textarea>
</div>


<script>
  function changeVal() {
    getNthMouseCoord = +nthMouseCoord.value + 1;
    simplify = +simplifyDrawing.value;;
  }

  function clearDrawing() {
    let paths = svg.querySelectorAll('path');
    paths.forEach(path => {
      path.remove();
    })
  }
</script>

How it works

  • save mouse/cursor positions in a point array via event listeners

Event Listeners (including touch events):

function getMouseOrTouchPos(e) {
  let x, y;
  // touch cooordinates
  if (e.type == "touchstart" || e.type == "touchmove" || e.type == "touchend" || e.type == "touchcancel"
  ) {
    let evt = typeof e.originalEvent === "undefined" ? e : e.originalEvent;
    let touch = evt.touches[0] || evt.changedTouches[0];
    x = touch.pageX;
    y = touch.pageY;
  } else if ( e.type == "mousedown" || e.type == "mouseup" || e.type == "mousemove" || e.type == "mouseover" || e.type == "mouseout" || e.type == "mouseenter" || e.type == "mouseleave") {
    x = e.clientX;
    y = e.clientY;
  }

  // get svg user space coordinates
  let point = svg.createSVGPoint();
  point.x = x;
  point.y = y;
  let ctm = svg.getScreenCTM().inverse();
  point = point.matrixTransform(ctm);
  return point;
}

It's crucial to translate HTML DOM cursor coordinates to SVG DOM user units unless your svg viewport corresponds to the HTML placement 1:1.

  let ctm = svg.getScreenCTM().inverse();
  point = point.matrixTransform(ctm);
  • optional: skip cursor points and use every nth point respectively (pre processing – aimed at reducing the total amount of cursor coordinates)
  • optional: similar to the previous measure: smooth by skipping polyine segments – the curve control point calculation will skip succeeding mid and control points (post processing – calculate curves based on retrieved point array but skip points).
  • Q to T simplification: Since we are splitting the polyline coordinates evenly we can simplify the path d output by using the quadratic shorthand command T repeating the previous tangents.
  • Converting to relative commands and rounding
    Based on x/y offsets globally incremented by the previous command's end point.

Depending on your layout sizes you need to tweak smoothing values.

For a "micro smoothing" you should also include these css properties:

path {
  fill: none;
  stroke: #000;
  stroke-linecap: round;
  stroke-linejoin: round;
}

Further reading

Change T command to Q command in SVG

like image 1
herrstrietzel Avatar answered Nov 16 '22 01:11

herrstrietzel