Smooth canvas line and on curser start point

前端 未结 2 869
感情败类
感情败类 2020-12-06 23:26

I am attempting to create a canvas web application and having two main problems. I would love to make the pen tool draw more smoothly. Secondly, each time I clear the sketch

相关标签:
2条回答
  • 2020-12-06 23:57

    First the problem of the line starting in the wrong spot. You are forgetting to finish the path you create. You have beginPath, and moveTo but you leave it hanging. You need to call stroke once when the mouse button is up.

    Smoothing.

    Line smoothing is a very complicated thing to do with many professional drawing apps tackling the problem with a variety of solutions. There does not seem to be one agreed upon method. The big problem is.. How do you smooth a line but not destroy the desired line? and How do you do it quickly????

    Here I present a two stage process.

    Reduce the line complexity

    Step one, reduce the line complexity. Sampling the mouse gives way to many points. So I need to reduce the number of points, but not lose any details.

    I use the Ramer–Douglas–Peucker algorithm. It's quick and does a good job of reducing the complexity (number of points) of a line. Below you can find my implementation of the algorithm. It's not the best as it could do with some optimisation. You could most likely find it in some other language and port it to javascript.

    It uses a recursive function to reduce complexity based on length and angle between line segments. At its core is the dot product of two line segments, it is a quick way of determining the angle between the two segments. See the supplied link above for more details.

    // Line simplification based on
    // the Ramer–Douglas–Peucker algorithm
    // referance https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm
    // points: are and array of arrays consisting of [[x,y],[x,y],...,[x,y]]
    // length: is in pixels and is the square of the actual distance.
    // returns array of points of the same form as the input argument points.
    var simplifyLineRDP = function(points, length) {
        var simplify = function(start, end) { // recursive simplifies points from start to end
            var maxDist, index, i, xx , yy, dx, dy, ddx, ddy, p1, p2, p, t, dist, dist1;
            p1 = points[start];
            p2 = points[end];   
            xx = p1[0];
            yy = p1[1];
            ddx = p2[0] - xx;
            ddy = p2[1] - yy;
            dist1 = (ddx * ddx + ddy * ddy);
            maxDist = length;
            for (var i = start + 1; i < end; i++) {
                p = points[i];
                if (ddx !== 0 || ddy !== 0) {
                   // dot product
                    t = ((p[0] - xx) * ddx + (p[1] - yy) * ddy) / dist1;
                    if (t > 1) {
                        dx = p[0] - p2[0];
                        dy = p[1] - p2[1];
                    } else 
                    if (t > 0) {
                        dx = p[0] - (xx + ddx * t);
                        dy = p[1] - (yy + ddy * t);
                    } else {
                        dx = p[0] - xx;
                        dy = p[1] - yy;
                    }
                }else{
                    dx = p[0] - xx;
                    dy = p[1] - yy;
                }
                dist = dx * dx + dy * dy 
                if (dist > maxDist) {
                    index = i;
                    maxDist = dist;
                }
            }
    
            if (maxDist > length) { // continue simplification while maxDist > length
                if (index - start > 1){
                    simplify(start, index);
                }
                newLine.push(points[index]);
                if (end - index > 1){
                    simplify(index, end);
                }
            }
        }    
        var end = points.length - 1;
        var newLine = [points[0]];
        simplify(0, end);
        newLine.push(points[end]);
        return newLine;
    }
    

    Smoothing using bezier curves

    Next the smoothing. As the line has been simplified it if reasonably quick to then compare the angles between the many lines and create a bezier if the angle is below a required threshold.

    Below is a example of how I do it. Though this will not fit the original line it is just concerned with smoothing. It is again a bit of a hack on my part and not based on any tried and tested algorithm. I have another one that does a bezier fit but that is too slow for the example.

    Basicly it steps through the line segments and calculates the angle between two segments, if the angle is below the threshold it then adds bezier control points along the tangent of the two line segments, making either 2nd order or 3rd order beziers depending on whether two consecutive points are smoothed. This is a stripped down version of a much more complicated algorithm so excuse the mess.

    // This is my own smoothing method The blindman`s smoother
    // It creates a set of bezier control points either 2nd order or third order 
    // bezier curves.
    // points: list of points [[x,y],[x,y],...,[x,y]]
    // cornerThres: when to smooth corners and represents the angle between to lines. 
    //     When the angle is smaller than the cornerThres then smooth.
    // match: if true then the control points will be balanced.
    // Function will make a copy of the points
    // returns [[x,y],[x,y,bx,by],[x,y,b1x,b1y,b2x,b2y],.....] with x and y line points
    // bx,by control points for 2nd order bezier and b1x,b1y,b2x,b2y the control
    // points for 3rd order bezier. These are mixed as needed. Test the length of
    // each point array to work out which bezier if any to use.
    
    var smoothLine = function(points,cornerThres,match){  // adds bezier control points at points if lines have angle less than thres
        var  p1, p2, p3, dist1, dist2, x, y, endP, len, angle, i, newPoints, aLen, closed, bal, cont1, nx1, nx2, ny1, ny2, np;
        function dot(x, y, xx, yy) {  // get do product
            // dist1,dist2,nx1,nx2,ny1,ny2 are the length and  normals and used outside function
            // normalise both vectors
            dist1 = Math.sqrt(x * x + y * y); // get length
            if (dist1  > 0) {  // normalise
                nx1 = x / dist1 ;
                ny1 = y / dist1 ;
            }else {
                nx1 = 1;  // need to have something so this will do as good as anything
                ny1 = 0;
            }
            dist2  = Math.sqrt(xx * xx + yy * yy);
            if (dist2  > 0) {
                nx2 = xx / dist2;
                ny2 = yy / dist2;
            }else {
                nx2 = 1;
                ny2 = 0;
            }
           return Math.acos(nx1 * nx2 + ny1 * ny2 ); // dot product
        }
        newPoints = []; // array for new points
        aLen = points.length;
        if(aLen <= 2){  // nothing to if line too short
            for(i = 0; i < aLen; i ++){  // ensure that the points are copied          
                newPoints.push([points[i][0],points[i][1]]);
            }
            return newPoints;
        }
        p1 = points[0];
        endP =points[aLen-1];
        i = 0;  // start from second poitn if line not closed
        closed = false;
        len = Math.hypot(p1[0]- endP[0], p1[1]-endP[1]);
        if(len < Math.SQRT2){  // end points are the same. Join them in coordinate space
            endP =  p1;
            i = 0;             // start from first point if line closed
            p1 = points[aLen-2];
            closed = true;
        }       
        newPoints.push([points[i][0],points[i][1]])
        for(; i < aLen-1; i++){
            p2 = points[i];
            p3 = points[i + 1];
            angle = Math.abs(dot(p2[0] - p1[0], p2[1] - p1[1], p3[0] - p2[0], p3[1] - p2[1]));
            if(dist1 !== 0){  // dist1 and dist2 come from dot function
                if( angle < cornerThres*3.14){ // bend it if angle between lines is small
                      if(match){
                          dist1 = Math.min(dist1,dist2);
                          dist2 = dist1;
                      }
                      // use the two normalized vectors along the lines to create the tangent vector
                      x = (nx1 + nx2) / 2;  
                      y = (ny1 + ny2) / 2;
                      len = Math.sqrt(x * x + y * y);  // normalise the tangent
                      if(len === 0){
                          newPoints.push([p2[0],p2[1]]);                                  
                      }else{
                          x /= len;
                          y /= len;
                          if(newPoints.length > 0){
                              var np = newPoints[newPoints.length-1];
                              np.push(p2[0]-x*dist1*0.25);
                              np.push(p2[1]-y*dist1*0.25);
                          }
                          newPoints.push([  // create the new point with the new bezier control points.
                                p2[0],
                                p2[1],
                                p2[0]+x*dist2*0.25,
                                p2[1]+y*dist2*0.25
                          ]);
                      }
                }else{
                    newPoints.push([p2[0],p2[1]]);            
                }
            }
            p1 = p2;
        }  
        if(closed){ // if closed then copy first point to last.
            p1 = [];
            for(i = 0; i < newPoints[0].length; i++){
                p1.push(newPoints[0][i]);
            }
            newPoints.push(p1);
        }else{
            newPoints.push([points[points.length-1][0],points[points.length-1][1]]);      
        }
        return newPoints;    
    }
    

    As I did not put that much thought into ease of use you will have to use the following function to render the resulting line.

    var drawSmoothedLine = function(line){
        var i,p;
        ctx.beginPath()
        ctx.moveTo(line[0][0],line[0][1])
        for(i = 0; i < line.length-1; i++){
           p = line[i];
           p1 = line[i+1]
           if(p.length === 2){ // linear 
                ctx.lineTo(p[0],p[1])
           }else
           if(p.length === 4){ // bezier 2nd order
               ctx.quadraticCurveTo(p[2],p[3],p1[0],p1[1]);
           }else{              // bezier 3rd order
               ctx.bezierCurveTo(p[2],p[3],p[4],p[5],p1[0],p1[1]);
           }
        }
        if(p.length === 2){
            ctx.lineTo(p1[0],p1[1])
        }
        ctx.stroke();
    }
    

    So to use these to smooth a line. Simply capture the mouse points as you draw. When the done, then send the points to both functions in turn. Erase the drawn line and replace it with the new line. The is a bit of a lag between pen up and the smoothed result, but there is plenty of room for improvement in both functions.

    To put it all together I have added a snippet below. The two bars at the top left control the smoothing and detail. The bottom bar controls the first function described above and the top controls the smoothing (bezier) the more red you see the smoother the lines and greater the detail reduction.

    Middle mouse button clears or just restart.

    Sorry, this was more work than I expected so the comments are a little sparse. I will improve the comments as time permits..

    var canvas = document.getElementById("canV"); 
    var ctx = canvas.getContext("2d");
    
    
    // mouse stuff
    var mouse = {
        x:0,
        y:0,
        buttonLastRaw:0, // user modified value 
        buttonRaw:0,
        buttons:[1,2,4,6,5,3], // masks for setting and clearing button raw bits;
    };
    function mouseMove(event){
        mouse.x = event.offsetX;  mouse.y = event.offsetY; 
        if(mouse.x === undefined){ mouse.x = event.clientX;  mouse.y = event.clientY;}    
        if(event.type === "mousedown"){ mouse.buttonRaw |= mouse.buttons[event.which-1];
        }else if(event.type === "mouseup"){mouse.buttonRaw &= mouse.buttons[event.which+2];
        }else if(event.type === "mouseout"){ mouse.buttonRaw = 0; mouse.over = false;
        }else if(event.type === "mouseover"){ mouse.over = true; }
        event.preventDefault();
    }
    canvas.addEventListener('mousemove',mouseMove);
    canvas.addEventListener('mousedown',mouseMove);
    canvas.addEventListener('mouseup'  ,mouseMove); 
    canvas.addEventListener('mouseout'  ,mouseMove); 
    canvas.addEventListener('mouseover'  ,mouseMove); 
    canvas.addEventListener("contextmenu", function(e){ e.preventDefault();}, false);
    
    
    // Line simplification based on
    // the Ramer–Douglas–Peucker algorithm
    // referance https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm
    // points are and array of arrays consisting of [[x,y],[x,y],...,[x,y]]
    // length is in pixels and is the square of the actual distance.
    // returns array of points of the same form as the input argument points.
    var simplifyLineRDP = function(points, length) {
        var simplify = function(start, end) { // recursize simplifies points from start to end
            var maxDist, index, i, xx , yy, dx, dy, ddx, ddy, p1, p2, p, t, dist, dist1;
            p1 = points[start];
            p2 = points[end];   
            xx = p1[0];
            yy = p1[1];
            ddx = p2[0] - xx;
            ddy = p2[1] - yy;
            dist1 = (ddx * ddx + ddy * ddy);
            maxDist = length;
            for (var i = start + 1; i < end; i++) {
                p = points[i];
                if (ddx !== 0 || ddy !== 0) {
                    t = ((p[0] - xx) * ddx + (p[1] - yy) * ddy) / dist1;
                    if (t > 1) {
                        dx = p[0] - p2[0];
                        dy = p[1] - p2[1];
                    } else 
                    if (t > 0) {
                        dx = p[0] - (xx + ddx * t);
                        dy = p[1] - (yy + ddy * t);
                    } else {
                        dx = p[0] - xx;
                        dy = p[1] - yy;
                    }
                }else{
                    dx = p[0] - xx;
                    dy = p[1] - yy;
                }
                dist = dx * dx + dy * dy 
                if (dist > maxDist) {
                    index = i;
                    maxDist = dist;
                }
            }
    
            if (maxDist > length) { // continue simplification while maxDist > length
                if (index - start > 1){
                    simplify(start, index);
                }
                newLine.push(points[index]);
                if (end - index > 1){
                    simplify(index, end);
                }
            }
        }    
        var end = points.length - 1;
        var newLine = [points[0]];
        simplify(0, end);
        newLine.push(points[end]);
        return newLine;
    }
    
    
    
    // This is my own smoothing method 
    // It creates a set of bezier control points either 2nd order or third order 
    // bezier curves.
    // points: list of points
    // cornerThres: when to smooth corners and represents the angle between to lines. 
    //     When the angle is smaller than the cornerThres then smooth.
    // match: if true then the control points will be balanced.
    // Function will make a copy of the points
    
    var smoothLine = function(points,cornerThres,match){  // adds bezier control points at points if lines have angle less than thres
        var  p1, p2, p3, dist1, dist2, x, y, endP, len, angle, i, newPoints, aLen, closed, bal, cont1, nx1, nx2, ny1, ny2, np;
        function dot(x, y, xx, yy) {  // get do product
            // dist1,dist2,nx1,nx2,ny1,ny2 are the length and  normals and used outside function
            // normalise both vectors
            dist1 = Math.sqrt(x * x + y * y); // get length
            if (dist1  > 0) {  // normalise
                nx1 = x / dist1 ;
                ny1 = y / dist1 ;
            }else {
                nx1 = 1;  // need to have something so this will do as good as anything
                ny1 = 0;
            }
            dist2  = Math.sqrt(xx * xx + yy * yy);
            if (dist2  > 0) {
                nx2 = xx / dist2;
                ny2 = yy / dist2;
            }else {
                nx2 = 1;
                ny2 = 0;
            }
           return Math.acos(nx1 * nx2 + ny1 * ny2 ); // dot product
        }
        newPoints = []; // array for new points
        aLen = points.length;
        if(aLen <= 2){  // nothing to if line too short
            for(i = 0; i < aLen; i ++){  // ensure that the points are copied          
                newPoints.push([points[i][0],points[i][1]]);
            }
            return newPoints;
        }
        p1 = points[0];
        endP =points[aLen-1];
        i = 0;  // start from second poitn if line not closed
        closed = false;
        len = Math.hypot(p1[0]- endP[0], p1[1]-endP[1]);
        if(len < Math.SQRT2){  // end points are the same. Join them in coordinate space
            endP =  p1;
            i = 0;             // start from first point if line closed
            p1 = points[aLen-2];
            closed = true;
        }       
        newPoints.push([points[i][0],points[i][1]])
        for(; i < aLen-1; i++){
            p2 = points[i];
            p3 = points[i + 1];
            angle = Math.abs(dot(p2[0] - p1[0], p2[1] - p1[1], p3[0] - p2[0], p3[1] - p2[1]));
            if(dist1 !== 0){  // dist1 and dist2 come from dot function
                if( angle < cornerThres*3.14){ // bend it if angle between lines is small
                      if(match){
                          dist1 = Math.min(dist1,dist2);
                          dist2 = dist1;
                      }
                      // use the two normalized vectors along the lines to create the tangent vector
                      x = (nx1 + nx2) / 2;  
                      y = (ny1 + ny2) / 2;
                      len = Math.sqrt(x * x + y * y);  // normalise the tangent
                      if(len === 0){
                          newPoints.push([p2[0],p2[1]]);                                  
                      }else{
                          x /= len;
                          y /= len;
                          if(newPoints.length > 0){
                              var np = newPoints[newPoints.length-1];
                              np.push(p2[0]-x*dist1*0.25);
                              np.push(p2[1]-y*dist1*0.25);
                          }
                          newPoints.push([  // create the new point with the new bezier control points.
                                p2[0],
                                p2[1],
                                p2[0]+x*dist2*0.25,
                                p2[1]+y*dist2*0.25
                          ]);
                      }
                }else{
                    newPoints.push([p2[0],p2[1]]);            
                }
            }
            p1 = p2;
        }  
        if(closed){ // if closed then copy first point to last.
            p1 = [];
            for(i = 0; i < newPoints[0].length; i++){
                p1.push(newPoints[0][i]);
            }
            newPoints.push(p1);
        }else{
            newPoints.push([points[points.length-1][0],points[points.length-1][1]]);      
        }
        return newPoints;    
    }
    
    // creates a drawable image
    var createImage = function(w,h){
        var image = document.createElement("canvas");
        image.width = w;
        image.height =h; 
        image.ctx = image.getContext("2d"); 
        return image;
    }  
    
    // draws the smoothed line with bezier control points.
    var drawSmoothedLine = function(line){
        var i,p;
        ctx.beginPath()
        ctx.moveTo(line[0][0],line[0][1])
        for(i = 0; i < line.length-1; i++){
           p = line[i];
           p1 = line[i+1]
           if(p.length === 2){ // linear 
                ctx.lineTo(p[0],p[1])
           }else
           if(p.length === 4){ // bezier 2nd order
               ctx.quadraticCurveTo(p[2],p[3],p1[0],p1[1]);
           }else{              // bezier 3rd order
               ctx.bezierCurveTo(p[2],p[3],p[4],p[5],p1[0],p1[1]);
           }
        }
        if(p.length === 2){
            ctx.lineTo(p1[0],p1[1])
        }
        ctx.stroke();
    }
    
    // smoothing settings
    var lineSmooth = {};
    lineSmooth.lengthMin = 8;  // square of the pixel length
    lineSmooth.angle = 0.8;      // angle threshold
    lineSmooth.match = false;  // not working.
    // back buffer to save the canvas allowing the new line to be erased
    var backBuffer = createImage(canvas.width,canvas.height);
    var currentLine = [];
    mouse.lastButtonRaw = 0;  // add mouse last incase not there
    ctx.lineWidth = 3;
    ctx.lineJoin = "round";
    ctx.lineCap = "round";
    ctx.strokeStyle = "black";
    ctx.clearRect(0,0,canvas.width,canvas.height);
    var drawing = false;  // if drawing
    var input = false;  // if menu input
    var smoothIt = false;  // flag to allow feedback that smoothing is happening as it takes some time.
    function draw(){
        // if not drawing test for menu interaction and draw the menus
        if(!drawing){      
            if(mouse.x < 203 && mouse.y < 24){
                if(mouse.y < 13){
                    if(mouse.buttonRaw === 1){
                        ctx.clearRect(3,3,200,10);
                        lineSmooth.angle = (mouse.x-3)/200;
                        input = true;
                    }
                }else
                if(mouse.buttonRaw === 1){
                    ctx.clearRect(3,14,200,10);
                    lineSmooth.lengthMin = (mouse.x-3)/10;
                    input = true;
                }
                    
                canvas.style.cursor = "pointer";
            }else{
                canvas.style.cursor = "crosshair";
                
            }
            if(mouse.buttonRaw === 0 && input){
                input = false;
                mouse.lastButtonRaw = 0;
            }
            ctx.lineWidth = 1;
            ctx.fillStyle = "red";
            ctx.fillRect(3,3,lineSmooth.angle*200,10);
            ctx.fillRect(3,14,lineSmooth.lengthMin*10,10);
    
            ctx.textAlign = "left";
            ctx.textBaseline = "top";
            ctx.fillStyle = "#5F2"
            ctx.strokeRect(3,3,200,10);
            ctx.fillText("Smooth",5,2)
            ctx.strokeRect(3,14,200,10);
            ctx.fillText("Detail",5,13);
    
        }else{
            canvas.style.cursor = "crosshair"; 
        }
        if(!input){
             ctx.lineWidth = 3;
    
            if(mouse.buttonRaw === 1 && mouse.lastButtonRaw === 0){
                currentLine = [];
                drawing  = true;
    
                backBuffer.ctx.clearRect(0,0,canvas.width,canvas.height);
                backBuffer.ctx.drawImage(canvas,0,0);
                currentLine.push([mouse.x,mouse.y])
            }else
            if(mouse.buttonRaw === 1){
                var lp = currentLine[currentLine.length-1]; // get last point
                // dont record point if no movement
                if(mouse.x !== lp[0] || mouse.y !== lp[1] ){
                    currentLine.push([mouse.x,mouse.y]);
                    ctx.beginPath();
                    ctx.moveTo(lp[0],lp[1])
                    ctx.lineTo(mouse.x,mouse.y);
                    ctx.stroke();
                }
            }else
            if(mouse.buttonRaw === 0 && mouse.lastButtonRaw === 1){
                ctx.textAlign = "center"
                ctx.fillStyle = "red"
                ctx.fillText("Smoothing...",canvas.width/2,canvas.height/5);
                smoothIt = true;
            }else
            if(smoothIt){
                smoothIt = false;
                
                var newLine = smoothLine(
                    simplifyLineRDP(
                        currentLine,
                        lineSmooth.lengthMin
                    ),
                    lineSmooth.angle,
                    lineSmooth.match
                );
                ctx.clearRect(0,0,canvas.width,canvas.height);
                ctx.drawImage(backBuffer,0,0);
                drawSmoothedLine(newLine);
                drawing  = false;
                
            }
        }
        // middle button clear
        if(mouse.buttonRaw === 2){
            ctx.clearRect(0,0,canvas.width,canvas.height);
        }
        mouse.lastButtonRaw = mouse.buttonRaw;
    
        requestAnimationFrame(draw);
    
    }
    
    draw();
    .canC { width:1000px;  height:500px;}
    <canvas class="canC" id="canV" width=1000 height=500></canvas>

    0 讨论(0)
  • 2020-12-07 00:13

    I would love to make the pen tool draw more smoothly

    Use can use quadratic curves instead of lines:

    ctx.quadraticCurveTo(cpx, cpy, x, y);
    

    Example: http://www.w3schools.com/tags/canvas_quadraticcurveto.asp

    0 讨论(0)
提交回复
热议问题