0

Let's say I have a (possibly closed) polygonal shape in SVG, i.e. adjacent straight lines. I would like to round off an arbitrary vertex of this shape, similar to the effect of the CSS border-radius property.

I'm aware that <rect> supports rx and ry attributes, but my shape isn't necessarily a rectangle.

I'm aware that <polygon> and <polyline> elements don't support radius, and an easy solution is to set a thicker stroke (Svg polygon rounding). But the thicker stroke applies to all vertices of this shape. Instead I want to round off arbitrary vertices.

I'm also aware I can use <path> (SVG rounded corner) with A commands. But A commands draw elliptical arcs. I want the vertex to look smooth.

This is an example of what I want. Basically, an elbow:

enter image description here

Additionally, I want the "radius" of this elbow to be dependent on user input. In other words, the user can choose how round the elbow looks.

blackgreen
  • 34,072
  • 23
  • 111
  • 129

1 Answers1

1

The solution I came up with involves using the SVG Q command, which draws a quadratic bezier curve. The main syntax, as it appears in a d attribute, is:

Q x,y px,py

where x,y is the control point of the curve and px,py is the end point of the curve. I find this picture to be helpful:

enter image description here

So in order for this curve to look like a natural continuation of the two sides of the elbow, the derivative at the start and end point of the curve must be equal to the slope of the two straight lines.

Let's consider a <path> that forms a sharp corner at P(145,75):

<path d="M 20,40 L 145,75 L 35,175.5" stroke="black"/>

Following the picture above, that corner is then the control point of the quadratic curve. You now want to stop the straight line L at a certain distance from the vertex, calculated from user input, and that will be the start point of the quadratic bezier. The end point will be the px,py shown above.

Therefore we can modify our path like this (with string interpolation):

<path d=`M 20,40 L ${bezierStart} Q ${vertex} ${bezierEnd} L 35,175.5` stroke="black"/>

Graphically:

enter image description here

In order to calculate all this you only need the three points that identify the triangle rooted at the vertex you want to round. The calculation is based on this Math.SE post except that the d/dt ratio mentioned over there in this case is already known: it comes from the user.

In my case, I let the user input integers as if they were setting a border-radius property, and then divide by 100, so that the user input translates to an offset from the vertex point.

The function is as follows (in Typescript):

type Point = [number,number]

type QBezierAnchors = {
   vertex: Point
   start: Point
   end: Point
}

// p: corner to round off
// a1: left anchor
// a2: right anchor
//   as if the polygonal shape is drawn from a1 -> p -> a2
// r: user-supplied radius
export function quadraticBezier(p: Point, a1: Point, a2: Point, r: number): QBezierAnchors {
    // the corner to round is also the control point of the quadratic bezier
    const ctrl = p

    // formula for finding point p at a certain distance from p0 on a line that passes through p1
    // px = (1-t)p0x + tp1x
    // py = (1-t)p0y + tp1y
    // t is ratio of distances d/dt where d = distance(p0,p1) and dt = distance(p0,p)
    // but in our case we already know the ratio because it's set by the user
    const t = r / 100

    const start = [((1-t)*p[0] + (t*a1[0])), ((1-t)*p[1] + (t*a1[1]))]
    const end = [((1-t)*p[0] + (t*a2[0])), ((1-t)*p[1] + (t*a2[1]))]
    return {
        vertex: ctrl,
        start,
        end
    }
}

Then just format the output of this function into the <path> element as shown above.

Runnable example:

function initialize() {
  document.querySelectorAll(".myInput").forEach((v) => {
    v.addEventListener("keyup", update)
  })

  update()
}

function update() {
  const startX = document.getElementById("startX").value
  const startY = document.getElementById("startY").value
  const vX = document.getElementById("vX").value
  const vY = document.getElementById("vY").value
  const endX = document.getElementById("endX").value
  const endY = document.getElementById("endY").value
  const radius = document.getElementById("userRadius").value

  const q = quadraticBezier([vX, vY], [startX, startY], [endX, endY], radius)

  const path = document.getElementById("thePath")
  path.setAttribute("d", `M ${startX},${startY} L ${q.start} Q ${q.ctrl} ${q.end} L ${endX},${endY}`)
}

function quadraticBezier(p, a1, a2, r) {
  const ctrl = p
  const t = r / 100
  const start = [((1 - t) * p[0] + (t * a1[0])), ((1 - t) * p[1] + (t * a1[1]))]
  const end = [((1 - t) * p[0] + (t * a2[0])), ((1 - t) * p[1] + (t * a2[1]))]
  return {
    ctrl,
    start,
    end
  }
}

initialize()
.myInput {
  margin-left: 5px;
  margin-top: 5px;
}
<div>
  <label>Start x</label><input id="startX" class="myInput" value="20" />
  <label>y</label><input id="startY" class="myInput" value="40" />
</div>
<div>
  <label>Vertex x</label><input id="vX" class="myInput" value="145" />
  <label>y</label><input id="vY" class="myInput" value="75" />
</div>
<div>
  <label>End x</label><input id="endX" class="myInput" value="35" />
  <label>y</label><input id="endY" class="myInput" value="175" />
</div>
<div>
  <label>Radius</label><input id="userRadius" class="myInput" value="10" />
</div>
<div>
  <svg width="240" height="320" viewBox="0 0 240 320" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="thePath" stroke="black" stroke-width="4"/>
</svg>
</div>
blackgreen
  • 34,072
  • 23
  • 111
  • 129