Consider a path through three points \(A\), \(B\), and \(C\). The two segments meet at \(B\), where we want to replace the sharp corner by a circular arc of radius \(r\). The arc must touch both segments tangentially, so the straight path and the arc share the same direction at their points of contact.
The construction must determine the tangent point \(X\) on \(BA\), the tangent point \(Y\) on \(BC\), and the center \(M\) of the circular arc.
Geometry of the Fillet
Let the vectors from the corner toward the adjacent vertices be
\[ \mathbf{a}=A-B, \qquad \mathbf{b}=C-B. \]
Their lengths and unit directions are
\[ l_a=\lVert\mathbf{a}\rVert, \qquad l_b=\lVert\mathbf{b}\rVert, \qquad \hat{\mathbf{a}}=\frac{\mathbf{a}}{l_a}, \qquad \hat{\mathbf{b}}=\frac{\mathbf{b}}{l_b}. \]
Write \(\theta\) for the angle between \(\hat{\mathbf{a}}\) and \(\hat{\mathbf{b}}\). A circle tangent to both rays has equal perpendicular distance from them, so its center lies on the internal angle bisector. The right triangles \(BXM\) and \(BYM\) are congruent. Their tangent lengths are therefore equal:
\[ BX=BY=w. \]
In either right triangle,
\[ \tan\frac{\theta}{2}=\frac{r}{w}, \]
which gives
\[ \boxed{w=r\cot\frac{\theta}{2}}. \]
The tangent points follow immediately:
\[ \boxed{ X=B+w\hat{\mathbf{a}}, \qquad Y=B+w\hat{\mathbf{b}} }. \]
A Formula Without Angles
Evaluating \(\theta\) with an inverse trigonometric function is unnecessary. Define
\[ c=\hat{\mathbf{a}}\cdot\hat{\mathbf{b}}=\cos\theta, \qquad t=\lvert\hat{\mathbf{a}}\perp\hat{\mathbf{b}}\rvert=\sin\theta, \]
Here \(\perp\) denotes the 2D perp product:
\[ \hat{\mathbf{a}}\perp\hat{\mathbf{b}} =\hat{a}_x\hat{b}_y-\hat{a}_y\hat{b}_x. \]
The half-angle identity
\[ \cot\frac{\theta}{2}=\frac{1+\cos\theta}{\sin\theta} \]
turns the tangent distance into
\[ \boxed{w=r\frac{1+c}{t}}. \]
Efficient Evaluation from the Original Vectors
The normalized vectors are useful for understanding the geometry, but an implementation does not need to construct them. Define
\[ D=\mathbf{a}\cdot\mathbf{b}, \qquad K=\mathbf{a}\perp\mathbf{b}, \qquad Q=\lvert K\rvert, \qquad P=l_al_b. \]
Since \(c=D/P\) and \(t=Q/P\),
\[ \frac{1+c}{t} =\frac{1+D/P}{Q/P} =\frac{P+D}{Q}. \]
Thus the ratio between tangent distance and radius is
\[ q=\frac{w}{r}=\frac{P+D}{Q}. \]
An equivalent expression improves numerical stability in the range where \(P+D\) subtracts nearly equal values. Lagrange's identity gives
\[ D^2+Q^2=P^2, \]
so
\[ (P+D)(P-D)=Q^2 \]
and therefore
\[ \frac{P+D}{Q}=\frac{Q}{P-D}. \]
A stable evaluation uses
\[ \boxed{ q= \begin{cases} \dfrac{P+D}{Q}, & D\geq 0,\\[8pt] \dfrac{Q}{P-D}, & D<0. \end{cases} } \]
The tangent distance is then simply \(w=rq\). This form is efficient, but the geometric derivation remains essential: it explains what \(q\) means and why the calculation is correct.
Computing the Circle Center
Let \(R\) rotate a two-dimensional vector counterclockwise by \(90^\circ\):
\[ R(x,y)=(-y,x). \]
The radius at \(X\) is perpendicular to the first segment. The sign of \(K\) identifies which side of that segment contains the inside of the corner. Hence
\[ \boxed{M=X+\operatorname{sgn}(K)\,r\,R(\hat{\mathbf{a}})}. \]
Substituting \(\hat{\mathbf{a}}=\mathbf{a}/l_a\) gives the directly evaluable component form
\[ \boxed{ \begin{aligned} M_x &=X_x-\operatorname{sgn}(K)\frac{r}{l_a}a_y,\\ M_y &=X_y+\operatorname{sgn}(K)\frac{r}{l_a}a_x. \end{aligned} } \]
This computes the same center as the normalized angle bisector, but reuses values that are already needed for the tangent points.
Checking the Radius against Finite Segments
The derivation so far treats the adjacent segments as rays. On finite segments, both tangent points must stay between \(B\) and their respective endpoints. Therefore
\[ w\leq L, \qquad L=\min(l_a,l_b). \]
Because \(w=rq\), a given radius fits precisely when
\[ \boxed{rq\leq L}. \]
If this condition fails, the specified radius does not fit on the two segments. The implementation reports that case instead of silently constructing a different circle with a smaller radius.
Degenerate Geometry
Zero-length segments must be rejected because their directions are undefined. Collinearity can be tested without computing an angle:
\[ Q\leq\varepsilon P. \]
This condition is scale-independent because \(Q/P=\lvert\sin\theta\rvert\). The sign of \(D\) distinguishes the two collinear cases:
- If \(D<0\), the rays from \(B\) point in opposite directions. The path is already straight.
- If \(D>0\), both rays point in the same direction. The path reverses at \(B\), so there is no unique internal fillet.
JavaScript Implementation
The implementation mirrors the derivation and returns a semantic result object. Callers receive named points and measurements rather than having to remember numeric array positions. The calculation uses two square roots, one dot product, one perp product, and no trigonometric functions.
function roundedCorner(A, B, C, radius) {
const epsilon = 1e-12;
if (!(radius > 0)) {
throw new RangeError("The radius must be positive");
}
const ax = A.x - B.x;
const ay = A.y - B.y;
const bx = C.x - B.x;
const by = C.y - B.y;
const lenA2 = ax * ax + ay * ay;
const lenB2 = bx * bx + by * by;
if (!(lenA2 > 0) || !(lenB2 > 0)) {
throw new RangeError("Adjacent segments must have positive length");
}
const lenA = Math.sqrt(lenA2);
const lenB = Math.sqrt(lenB2);
const lenProduct = lenA * lenB;
const dot = ax * bx + ay * by;
const perp = ax * by - ay * bx;
const absPerp = Math.abs(perp);
if (absPerp <= epsilon * lenProduct) {
if (dot < 0) {
throw new RangeError("The path is already straight");
}
throw new RangeError("A reversing path has no unique internal fillet");
}
const q = dot >= 0
? (lenProduct + dot) / absPerp
: absPerp / (lenProduct - dot);
const w = radius * q;
const maxW = Math.min(lenA, lenB);
if (w > maxW) {
throw new RangeError("The radius does not fit on the adjacent segments");
}
const startFactor = w / lenA;
const endFactor = w / lenB;
const start = {
x: B.x + ax * startFactor,
y: B.y + ay * startFactor
};
const end = {
x: B.x + bx * endFactor,
y: B.y + by * endFactor
};
const normalFactor = (perp > 0 ? radius : -radius) / lenA;
const center = {
x: start.x - ay * normalFactor,
y: start.y + ax * normalFactor
};
return {
center,
start,
end,
radius,
tangentDistance: w
};
} start is the tangent point on \(BA\), end is the tangent point on \(BC\), and center is the center of the fillet circle. The returned radius is always the specified radius. If that radius does not fit on the adjacent segments, the function throws a RangeError.
Drawing the Fillet as an SVG Arc
SVG's elliptical-arc command needs two flags in addition to the radius and endpoint. The internal fillet derived above always sweeps through at most \(180^\circ\), so large-arc is 0. The sweep flag selects the side of the circle. When \(A\), \(B\), and \(C\) are already expressed in SVG user coordinates, where y increases downward, its value follows from the same perp product:
function roundedCornerPath(A, B, C, radius) {
const fillet = roundedCorner(A, B, C, radius);
const ax = A.x - B.x;
const ay = A.y - B.y;
const bx = C.x - B.x;
const by = C.y - B.y;
const sweep = ax * by - ay * bx < 0 ? 1 : 0;
return [
"M", A.x, A.y,
"L", fillet.start.x, fillet.start.y,
"A", radius, radius, 0, 0, sweep, fillet.end.x, fillet.end.y,
"L", C.x, C.y
].join(" ");
} If the geometry is computed in a mathematical coordinate system with y pointing upward and transformed only afterward, compute the sweep after that transformation or invert the flag. This coordinate-system distinction is why copying a fixed sweep value between a Cartesian sketch and an SVG path can put the arc on the wrong side.
Applications already using vector objects can express the same construction with Vector2.js.