简体   繁体   中英

html5 canvas triangle with rounded corners

I'm new to HTML5 Canvas and I'm trying to draw a triangle with rounded corners.

I have tried

ctx.lineJoin = "round";
ctx.lineWidth = 20;

but none of them are working.

Here's my code:

 var ctx = document.querySelector("canvas").getContext('2d'); ctx.scale(5, 5); var x = 18 / 2; var y = 0; var triangleWidth = 18; var triangleHeight = 8; // how to round this triangle?? ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + triangleWidth / 2, y + triangleHeight); ctx.lineTo(x - triangleWidth / 2, y + triangleHeight); ctx.closePath(); ctx.fillStyle = "#009688"; ctx.fill(); ctx.fillStyle = "#8BC34A"; ctx.fillRect(0, triangleHeight, 9, 126); ctx.fillStyle = "#CDDC39"; ctx.fillRect(9, triangleHeight, 9, 126);
 <canvas width="800" height="600"></canvas>

Could you help me?

Rounding corners

An invaluable function I use a lot is rounded polygon. It takes a set of 2D points that describe a polygon's vertices and adds arcs to round the corners.

The problem with rounding corners and keeping within the constraint of the polygons area is that you can not always fit a round corner that has a particular radius.

In these cases you can either ignore the corner and leave it as pointy or, you can reduce the rounding radius to fit the corner as best possible.

The following function will resize the corner rounding radius to fit the corner if the corner is too sharp and the lines from the corner not long enough to get the desired radius in.

Note the code has comments that refer to the Maths section below if you want to know what is going on.

roundedPoly(ctx, points, radius)

// ctx is the context to add the path to
// points is a array of points [{x :?, y: ?},...
// radius is the max rounding radius 
// this creates a closed polygon.
// To draw you must call between 
//    ctx.beginPath();
//    roundedPoly(ctx, points, radius);
//    ctx.stroke();
//    ctx.fill();
// as it only adds a path and does not render. 
function roundedPoly(ctx, points, radiusAll) {
  var i, x, y, len, p1, p2, p3, v1, v2, sinA, sinA90, radDirection, drawDirection, angle, halfAngle, cRadius, lenOut,radius;
  // convert 2 points into vector form, polar form, and normalised 
  var asVec = function(p, pp, v) {
    v.x = pp.x - p.x;
    v.y = pp.y - p.y;
    v.len = Math.sqrt(v.x * v.x + v.y * v.y);
    v.nx = v.x / v.len;
    v.ny = v.y / v.len;
    v.ang = Math.atan2(v.ny, v.nx);
  }
  radius = radiusAll;
  v1 = {};
  v2 = {};
  len = points.length;
  p1 = points[len - 1];
  // for each point
  for (i = 0; i < len; i++) {
    p2 = points[(i) % len];
    p3 = points[(i + 1) % len];
    //-----------------------------------------
    // Part 1
    asVec(p2, p1, v1);
    asVec(p2, p3, v2);
    sinA = v1.nx * v2.ny - v1.ny * v2.nx;
    sinA90 = v1.nx * v2.nx - v1.ny * -v2.ny;
    angle = Math.asin(sinA < -1 ? -1 : sinA > 1 ? 1 : sinA);
    //-----------------------------------------
    radDirection = 1;
    drawDirection = false;
    if (sinA90 < 0) {
      if (angle < 0) {
        angle = Math.PI + angle;
      } else {
        angle = Math.PI - angle;
        radDirection = -1;
        drawDirection = true;
      }
    } else {
      if (angle > 0) {
        radDirection = -1;
        drawDirection = true;
      }
    }
    if(p2.radius !== undefined){
        radius = p2.radius;
    }else{
        radius = radiusAll;
    }
    //-----------------------------------------
    // Part 2
    halfAngle = angle / 2;
    //-----------------------------------------

    //-----------------------------------------
    // Part 3
    lenOut = Math.abs(Math.cos(halfAngle) * radius / Math.sin(halfAngle));
    //-----------------------------------------

    //-----------------------------------------
    // Special part A
    if (lenOut > Math.min(v1.len / 2, v2.len / 2)) {
      lenOut = Math.min(v1.len / 2, v2.len / 2);
      cRadius = Math.abs(lenOut * Math.sin(halfAngle) / Math.cos(halfAngle));
    } else {
      cRadius = radius;
    }
    //-----------------------------------------
    // Part 4
    x = p2.x + v2.nx * lenOut;
    y = p2.y + v2.ny * lenOut;
    //-----------------------------------------
    // Part 5
    x += -v2.ny * cRadius * radDirection;
    y += v2.nx * cRadius * radDirection;
    //-----------------------------------------
    // Part 6
    ctx.arc(x, y, cRadius, v1.ang + Math.PI / 2 * radDirection, v2.ang - Math.PI / 2 * radDirection, drawDirection);
    //-----------------------------------------
    p1 = p2;
    p2 = p3;
  }
  ctx.closePath();
}

You may wish to add to each point a radius eg {x :10,y:10,radius:20} this will set the max radius for that point. A radius of zero will be no rounding.

The maths

The following illistration shows one of two possibilities, the angle to fit is less than 90deg, the other case (greater than 90) just has a few minor calculation differences (see code). 在角落显示圆圈

The corner is defined by the three points in red A , B , and C . The circle radius is r and we need to find the green points F the circle center and D and E which will define the start and end angles of the arc.

First we find the angle between the lines from B,A and B,C this is done by normalising the vectors for both lines and getting the cross product. ( Commented as Part 1 ) We also find the angle of line BC to the line at 90deg to BA as this will help determine which side of the line to put the circle.

Now we have the angle between the lines, we know that half that angle defines the line that the center of the circle will sit F but we do not know how far that point is from B ( Commented as Part 2 )

There are two right triangles BDF and BEF which are identical. We have the angle at B and we know that the side DF and EF are equal to the radius of the circle r thus we can solve the triangle to get the distance to F from B

For convenience rather than calculate to F is solve for BD ( Commented as Part 3 ) as I will move along the line BC by that distance ( Commented as Part 4 ) then turn 90deg and move up to F ( Commented as Part 5 ) This in the process gives the point D and moving along the line BA to E

We use points D and E and the circle center F (in their abstract form) to calculate the start and end angles of the arc. ( done in the arc function part 6 )

The rest of the code is concerned with the directions to move along and away from lines and which direction to sweep the arc.

The code section ( special part A ) uses the lengths of both lines BA and BC and compares them to the distance from BD if that distance is greater than half the line length we know the arc can not fit. I then solve the triangles to find the radius DF if the line BD is half the length of shortest line of BA and BC

Example use.

The snippet is a simple example of the above function in use. Click to add points to the canvas (needs a min of 3 points to create a polygon). You can drag points and see how the corner radius adapts to sharp corners or short lines. More info when snippet is running. To restart rerun the snippet. (there is a lot of extra code that can be ignored)

The corner radius is set to 30.

 const ctx = canvas.getContext("2d"); const mouse = { x: 0, y: 0, button: false, drag: false, dragStart: false, dragEnd: false, dragStartX: 0, dragStartY: 0 } function mouseEvents(e) { mouse.x = e.pageX; mouse.y = e.pageY; const lb = mouse.button; mouse.button = e.type === "mousedown" ? true : e.type === "mouseup" ? false : mouse.button; if (lb !== mouse.button) { if (mouse.button) { mouse.drag = true; mouse.dragStart = true; mouse.dragStartX = mouse.x; mouse.dragStartY = mouse.y; } else { mouse.drag = false; mouse.dragEnd = true; } } } ["down", "up", "move"].forEach(name => document.addEventListener("mouse" + name, mouseEvents)); const pointOnLine = {x:0,y:0}; function distFromLines(x,y,minDist){ var index = -1; const v1 = {}; const v2 = {}; const v3 = {}; const point = P2(x,y); eachOf(polygon,(p,i)=>{ const p1 = polygon[(i + 1) % polygon.length]; v1.x = p1.x - px; v1.y = p1.y - py; v2.x = point.x - px; v2.y = point.y - py; const u = (v2.x * v1.x + v2.y * v1.y)/(v1.y * v1.y + v1.x * v1.x); if(u >= 0 && u <= 1){ v3.x = px + v1.x * u; v3.y = py + v1.y * u; dist = Math.hypot(v3.y - point.y, v3.x - point.x); if(dist < minDist){ minDist = dist; index = i; pointOnLine.x = v3.x; pointOnLine.y = v3.y; } } }) return index; } function roundedPoly(ctx, points, radius) { var i, x, y, len, p1, p2, p3, v1, v2, sinA, sinA90, radDirection, drawDirection, angle, halfAngle, cRadius, lenOut; var asVec = function(p, pp, v) { vx = pp.x - px; vy = pp.y - py; v.len = Math.sqrt(vx * vx + vy * vy); v.nx = vx / v.len; v.ny = vy / v.len; v.ang = Math.atan2(v.ny, v.nx); } v1 = {}; v2 = {}; len = points.length; p1 = points[len - 1]; for (i = 0; i < len; i++) { p2 = points[(i) % len]; p3 = points[(i + 1) % len]; asVec(p2, p1, v1); asVec(p2, p3, v2); sinA = v1.nx * v2.ny - v1.ny * v2.nx; sinA90 = v1.nx * v2.nx - v1.ny * -v2.ny; angle = Math.asin(sinA); radDirection = 1; drawDirection = false; if (sinA90 < 0) { if (angle < 0) { angle = Math.PI + angle; } else { angle = Math.PI - angle; radDirection = -1; drawDirection = true; } } else { if (angle > 0) { radDirection = -1; drawDirection = true; } } halfAngle = angle / 2; lenOut = Math.abs(Math.cos(halfAngle) * radius / Math.sin(halfAngle)); if (lenOut > Math.min(v1.len / 2, v2.len / 2)) { lenOut = Math.min(v1.len / 2, v2.len / 2); cRadius = Math.abs(lenOut * Math.sin(halfAngle) / Math.cos(halfAngle)); } else { cRadius = radius; } x = p2.x + v2.nx * lenOut; y = p2.y + v2.ny * lenOut; x += -v2.ny * cRadius * radDirection; y += v2.nx * cRadius * radDirection; ctx.arc(x, y, cRadius, v1.ang + Math.PI / 2 * radDirection, v2.ang - Math.PI / 2 * radDirection, drawDirection); p1 = p2; p2 = p3; } ctx.closePath(); } const eachOf = (array, callback) => { var i = 0; while (i < array.length && callback(array[i], i++) !== true); }; const P2 = (x = 0, y = 0) => ({x, y}); const polygon = []; function findClosestPointIndex(x, y, minDist) { var index = -1; eachOf(polygon, (p, i) => { const dist = Math.hypot(x - px, y - py); if (dist < minDist) { minDist = dist; index = i; } }); return index; } // short cut vars var w = canvas.width; var h = canvas.height; var cw = w / 2; // center var ch = h / 2; var dragPoint; var globalTime; var closestIndex = -1; var closestLineIndex = -1; var cursor = "default"; const lineDist = 10; const pointDist = 20; var toolTip = ""; // main update function function update(timer) { globalTime = timer; cursor = "crosshair"; toolTip = ""; ctx.setTransform(1, 0, 0, 1, 0, 0); // reset transform ctx.globalAlpha = 1; // reset alpha if (w !== innerWidth - 4 || h !== innerHeight - 4) { cw = (w = canvas.width = innerWidth - 4) / 2; ch = (h = canvas.height = innerHeight - 4) / 2; } else { ctx.clearRect(0, 0, w, h); } if (mouse.drag) { if (mouse.dragStart) { mouse.dragStart = false; closestIndex = findClosestPointIndex(mouse.x,mouse.y, pointDist); if(closestIndex === -1){ closestLineIndex = distFromLines(mouse.x,mouse.y,lineDist); if(closestLineIndex === -1){ polygon.push(dragPoint = P2(mouse.x, mouse.y)); }else{ polygon.splice(closestLineIndex+1,0,dragPoint = P2(mouse.x, mouse.y)); } }else{ dragPoint = polygon[closestIndex]; } } dragPoint.x = mouse.x; dragPoint.y = mouse.y cursor = "none"; }else{ closestIndex = findClosestPointIndex(mouse.x,mouse.y, pointDist); if(closestIndex === -1){ closestLineIndex = distFromLines(mouse.x,mouse.y,lineDist); if(closestLineIndex > -1){ toolTip = "Click to cut line and/or drag to move."; } }else{ toolTip = "Click drag to move point."; closestLineIndex = -1; } } ctx.lineWidth = 4; ctx.fillStyle = "#09F"; ctx.strokeStyle = "#000"; ctx.beginPath(); roundedPoly(ctx, polygon, 30); ctx.stroke(); ctx.fill(); ctx.beginPath(); ctx.strokeStyle = "red"; ctx.lineWidth = 0.5; eachOf(polygon, p => ctx.lineTo(px,py) ); ctx.closePath(); ctx.stroke(); ctx.strokeStyle = "orange"; ctx.lineWidth = 1; eachOf(polygon, p => ctx.strokeRect(px-2,py-2,4,4) ); if(closestIndex > -1){ ctx.strokeStyle = "red"; ctx.lineWidth = 4; dragPoint = polygon[closestIndex]; ctx.strokeRect(dragPoint.x-4,dragPoint.y-4,8,8); cursor = "move"; }else if(closestLineIndex > -1){ ctx.strokeStyle = "red"; ctx.lineWidth = 4; var p = polygon[closestLineIndex]; var p1 = polygon[(closestLineIndex + 1) % polygon.length]; ctx.beginPath(); ctx.lineTo(px,py); ctx.lineTo(p1.x,p1.y); ctx.stroke(); ctx.strokeRect(pointOnLine.x-4,pointOnLine.y-4,8,8); cursor = "pointer"; } if(toolTip === "" && polygon.length < 3){ toolTip = "Click to add a corners of a polygon."; } canvas.title = toolTip; canvas.style.cursor = cursor; requestAnimationFrame(update); } requestAnimationFrame(update);
 canvas { border: 2px solid black; position: absolute; top: 0px; left: 0px; }
 <canvas id="canvas"></canvas>

I started by using @Blindman67 's answer, which works pretty well for basic static shapes.

I ran into the problem that when using the arc approach, having two points right next to each other is very different than having just one point. With two points next to each other, it won't be rounded, even if that is what your eye would expect. This is extra jarring if you are animating the polygon points.

I fixed this by using Bezier curves instead. IMO this is conceptually a little cleaner as well. I just make each corner with a quadratic curve where the control point is where the original corner was. This way, having two points in the same spot is virtually the same as only having one point.

I haven't compared performance but seems like canvas is pretty good at drawing Beziers.

As with @Blindman67 's answer, this doesn't actually draw anything so you will need to call ctx.startPath() before and ctx.stroke() after.

/**
 * Draws a polygon with rounded corners 
 * @param {CanvasRenderingContext2D} ctx The canvas context
 * @param {Array} points A list of `{x, y}` points
 * @radius {number} how much to round the corners
 */
function myRoundPolly(ctx, points, radius) {
    const distance = (p1, p2) => Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2)

    const lerp = (a, b, x) => a + (b - a) * x

    const lerp2D = (p1, p2, t) => ({
        x: lerp(p1.x, p2.x, t),
        y: lerp(p1.y, p2.y, t)
    })

    const numPoints = points.length

    let corners = []
    for (let i = 0; i < numPoints; i++) {
        let lastPoint = points[i]
        let thisPoint = points[(i + 1) % numPoints]
        let nextPoint = points[(i + 2) % numPoints]

        let lastEdgeLength = distance(lastPoint, thisPoint)
        let lastOffsetDistance = Math.min(lastEdgeLength / 2, radius)
        let start = lerp2D(
            thisPoint,
            lastPoint,
            lastOffsetDistance / lastEdgeLength
        )

        let nextEdgeLength = distance(nextPoint, thisPoint)
        let nextOffsetDistance = Math.min(nextEdgeLength / 2, radius)
        let end = lerp2D(
            thisPoint,
            nextPoint,
            nextOffsetDistance / nextEdgeLength
        )

        corners.push([start, thisPoint, end])
    }

    ctx.moveTo(corners[0][0].x, corners[0][0].y)
    for (let [start, ctrl, end] of corners) {
        ctx.lineTo(start.x, start.y)
        ctx.quadraticCurveTo(ctrl.x, ctrl.y, end.x, end.y)
    }

    ctx.closePath()
}

Styles for joining of lines such as ctx.lineJoin="round" apply to the stroke operation on paths - which is when their width, color, pattern, dash/dotted and similar line style attributes are taken into account.

Line styles do not apply to filling the interior of a path.

So to affect line styles a stroke operation is needed. In the following adaptation of posted code, I've translated canvas output to see the result without cropping, and stroked the triangle's path but not the rectangles below it:

 var ctx = document.querySelector("canvas").getContext('2d'); ctx.scale(5, 5); ctx.translate( 18, 12); var x = 18 / 2; var y = 0; var triangleWidth = 48; var triangleHeight = 8; // how to round this triangle?? ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + triangleWidth / 2, y + triangleHeight); ctx.lineTo(x - triangleWidth / 2, y + triangleHeight); ctx.closePath(); ctx.fillStyle = "#009688"; ctx.fill(); // stroke the triangle path. ctx.lineWidth = 3; ctx.lineJoin = "round"; ctx.strokeStyle = "orange"; ctx.stroke(); ctx.fillStyle = "#8BC34A"; ctx.fillRect(0, triangleHeight, 9, 126); ctx.fillStyle = "#CDDC39"; ctx.fillRect(9, triangleHeight, 9, 126);
 <canvas width="800" height="600"></canvas>

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM