SVG平滑手绘绘图

14

我使用本地JS实现了自由绘制路径,但是预料中路径的边缘有些粗糙而不平滑。因此,我有一个选项可以使用simplifyJS来简化点并重新绘制路径。但与这里一样,我试图在绘制时找到简化的边缘,而不是在绘制后进行平滑处理。

这是我的代码:

    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);
    };

但是它并没有像上面添加的链接中那样工作。边缘仍然不好。请帮忙。

输入图像描述 输入图像描述


3
目前您的问题描述不够清晰,请考虑添加更多信息,如屏幕截图、更详细的意图和实际结果等,以使问题更加明确。 - danyamachine
2
请帮助我们帮助您。请创建一个 MCVE,以便我们更清楚地了解您的问题。 - Paul LeBeau
@PaulLeBeau,我已经在上面添加了截图。由于我有很多依赖代码,所以创建fiddle有点困难。 - Exception
为什么不使用你参考链接中Mike Bostock提供的代码(https://bl.ocks.org/mbostock/f705fc55e6f26df29354)? - ConnorsFan
我没有使用d3 js。我尝试过只使用逻辑,但它不起作用。 - Exception
由于此问题设置了 d3.js 标签,我认为您正在使用它。您可以考虑删除该标签。 - ConnorsFan
3个回答

53
以下代码片段通过计算最近鼠标位置的平均值使曲线更加平滑。平滑程度取决于存储这些值的缓冲区大小。您可以尝试不同的缓冲区大小,以获得不同的平滑效果。使用12个点的缓冲区的行为与您在问题中提到的Mike Bostock's代码片段有些相似。

更复杂的技术可以实现从缓冲区存储的位置获取平滑点(加权平均、线性回归、三次样条平滑等),但这种简单的平均方法可能已经足够满足您的需求。

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">


9
这是一个非常棒的回答! - Exception
等待分配赏金,但我在接下来的50分钟内无法完成。 - Exception
3
非常酷的例子。我使用了你的代码在 Svelte 3 中创建了一个 SVG 线条编辑器:https://v3.svelte.technology/repl?version=3.0.0-alpha16&gist=f665ec6ed549fb35ac2f9878a5c9e1c7 - three
如何在上述中实现橡皮擦功能? - Vimesh Shah
1
我还将此适用于SVG绘图实用程序的一部分。感谢您的灵感! - Mumonkan
干得好,小伙子! - abberdeen

1

二次贝塞尔曲线折线平滑

@ConnorsFan的解决方案非常好,可能提供更好的渲染性能和更快速的绘图体验。
如果您需要更紧凑的svg输出(在标记大小方面),二次平滑可能会更有趣。
例如,如果您需要以有效的方式导出绘图。

简化示例:折线平滑

quadratic bezier polyline smoothing

绿色点显示原始折线坐标(用x/y对表示)。
紫色点表示插值的中间坐标-可以通过以下方式简单地计算得出:
[(x1+x2)/2, (y1+y2)/2].

原始坐标(高亮绿色)成为二次贝塞尔控制点
而插值的中间点将成为端点。

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>

示例:Svg绘图板

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://dev59.com/BVgQ5IYBdhLWcg3w7ofY#61732450
 */
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>

工作原理

  • 通过事件监听器将鼠标/光标位置保存在一个点数组中

事件监听器(包括触摸事件):

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;
}

除非您的SVG视口对应于HTML放置1:1,否则将HTML DOM光标坐标转换为SVG DOM用户单位至关重要。

  let ctm = svg.getScreenCTM().inverse();
  point = point.matrixTransform(ctm);
  • 可选:跳过光标点并分别使用每个第n个点(预处理-旨在减少光标坐标的总数)。
  • 可选:与前一个措施类似:通过跳过折线段进行平滑处理 - 曲线控制点计算将跳过连续的中间点和控制点(后处理 - 基于检索到的点数组计算曲线,但跳过点)。
  • QT 简化:由于我们均匀地分割了折线坐标,因此我们可以使用二次样条命令 T 重复先前的切线来简化 path d 输出。
  • 转换为相对命令并舍入
    基于上一个命令的终点全局递增的 x/y 偏移量。

根据您的布局大小,您需要调整平滑值。

对于“微平滑”,您还应包括以下 CSS 属性:

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

更多阅读

如何在SVG中将T命令改为Q命令


非常有用和有趣,谢谢! - purple

0

关于这个问题,github上已经有了一些实现,例如https://github.com/epistemex/cardinal-spline-js

你不需要对输入做任何更改,只需更改绘图函数,使点之间的线条平滑。这样,在简化过程中,点就不会有任何移动。


请问你能再更新一下 GitHub 链接吗?旧链接无法访问。 - undefined

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接