Skip to content

Commit

Permalink
Distribute rounding and smoothing budget properly
Browse files Browse the repository at this point in the history
  • Loading branch information
phamfoo committed Jun 30, 2022
1 parent 9801d5a commit 58640e3
Show file tree
Hide file tree
Showing 3 changed files with 447 additions and 274 deletions.
155 changes: 155 additions & 0 deletions src/distribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
interface RoundedRectangle {
topLeftCornerRadius: number
topRightCornerRadius: number
bottomRightCornerRadius: number
bottomLeftCornerRadius: number
width: number
height: number
}

interface NormalizedCorner {
radius: number
roundingAndSmoothingBudget: number
}

interface NormalizedCorners {
topLeft: NormalizedCorner
topRight: NormalizedCorner
bottomLeft: NormalizedCorner
bottomRight: NormalizedCorner
}

type Corner = keyof NormalizedCorners

type Side = 'top' | 'left' | 'right' | 'bottom'

interface Adjacent {
side: Side
corner: Corner
}

export function distributeAndNormalize({
topLeftCornerRadius,
topRightCornerRadius,
bottomRightCornerRadius,
bottomLeftCornerRadius,
width,
height,
}: RoundedRectangle): NormalizedCorners {
const roundingAndSmoothingBudgetMap: Record<Corner, number> = {
topLeft: -1,
topRight: -1,
bottomLeft: -1,
bottomRight: -1,
}

const cornerRadiusMap: Record<Corner, number> = {
topLeft: topLeftCornerRadius,
topRight: topRightCornerRadius,
bottomLeft: bottomLeftCornerRadius,
bottomRight: bottomRightCornerRadius,
}

Object.entries(cornerRadiusMap)
// Let the bigger corners choose first
.sort(([, radius1], [, radius2]) => {
return radius2 - radius1
})
.forEach(([cornerName, radius]) => {
const corner = cornerName as Corner
const adjacents = adjacentsByCorner[corner]

// Look at the 2 adjacent sides, figure out how much space we can have on both sides,
// then take the smaller one
const budget = Math.min.apply(
null,
adjacents.map((adjacent) => {
const adjacentCornerRadius = cornerRadiusMap[adjacent.corner]
if (radius === 0 && adjacentCornerRadius === 0) {
return 0
}

const adjacentCornerBudget =
roundingAndSmoothingBudgetMap[adjacent.corner]

const sideLength =
adjacent.side === 'top' || adjacent.side === 'bottom'
? width
: height

// If the adjacent corner's already given the rounding and smoothing budget,
// we'll just take the rest
if (adjacentCornerBudget >= 0) {
return sideLength - roundingAndSmoothingBudgetMap[adjacent.corner]
} else {
return (radius / (radius + adjacentCornerRadius)) * sideLength
}
})
)

roundingAndSmoothingBudgetMap[corner] = budget
cornerRadiusMap[corner] = Math.min(radius, budget)
})

return {
topLeft: {
radius: cornerRadiusMap.topLeft,
roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.topLeft,
},
topRight: {
radius: cornerRadiusMap.topRight,
roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.topRight,
},
bottomLeft: {
radius: cornerRadiusMap.bottomLeft,
roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.bottomLeft,
},
bottomRight: {
radius: cornerRadiusMap.bottomRight,
roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.bottomRight,
},
}
}

const adjacentsByCorner: Record<Corner, Array<Adjacent>> = {
topLeft: [
{
corner: 'topRight',
side: 'top',
},
{
corner: 'bottomLeft',
side: 'left',
},
],
topRight: [
{
corner: 'topLeft',
side: 'top',
},
{
corner: 'bottomRight',
side: 'right',
},
],
bottomLeft: [
{
corner: 'bottomRight',
side: 'bottom',
},
{
corner: 'topLeft',
side: 'left',
},
],
bottomRight: [
{
corner: 'bottomLeft',
side: 'bottom',
},
{
corner: 'topRight',
side: 'right',
},
],
}
220 changes: 220 additions & 0 deletions src/draw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
interface CornerPathParams {
a: number
b: number
c: number
d: number
p: number
cornerRadius: number
arcSectionLength: number
}

interface CornerParams {
cornerRadius: number
cornerSmoothing: number
preserveSmoothing: boolean
roundingAndSmoothingBudget: number
}

// The article from figma's blog
// https://www.figma.com/blog/desperately-seeking-squircles/
//
// The original code by MartinRGB
// https://github.com/MartinRGB/Figma_Squircles_Approximation/blob/bf29714aab58c54329f3ca130ffa16d39a2ff08c/js/rounded-corners.js#L64
export function getPathParamsForCorner({
cornerRadius,
cornerSmoothing,
preserveSmoothing,
roundingAndSmoothingBudget,
}: CornerParams): CornerPathParams {
// From figure 12.2 in the article
// p = (1 + cornerSmoothing) * q
// in this case q = R because theta = 90deg
let p = (1 + cornerSmoothing) * cornerRadius

// When there's not enough space left (p > roundingAndSmoothingBudget), there are 2 options:
//
// 1. What figma's currently doing: limit the smoothing value to make sure p <= roundingAndSmoothingBudget
// But what this means is that at some point when cornerRadius is large enough,
// increasing the smoothing value wouldn't do anything
//
// 2. Keep the original smoothing value and use it to calculate the bezier curve normally,
// then adjust the control points to achieve similar curvature profile
//
// preserveSmoothing is a new option I added
//
// If preserveSmoothing is on then we'll just keep using the original smoothing value
// and adjust the bezier curve later
if (!preserveSmoothing) {
const maxCornerSmoothing = roundingAndSmoothingBudget / cornerRadius - 1
cornerSmoothing = Math.min(cornerSmoothing, maxCornerSmoothing)
p = Math.min(p, roundingAndSmoothingBudget)
}

// In a normal rounded rectangle (cornerSmoothing = 0), this is 90
// The larger the smoothing, the smaller the arc
const arcMeasure = 90 * (1 - cornerSmoothing)
const arcSectionLength =
Math.sin(toRadians(arcMeasure / 2)) * cornerRadius * Math.sqrt(2)

// In the article this is the distance between 2 control points: P3 and P4
const angleAlpha = (90 - arcMeasure) / 2
const p3ToP4Distance = cornerRadius * Math.tan(toRadians(angleAlpha / 2))

// a, b, c and d are from figure 11.1 in the article
const angleBeta = 45 * cornerSmoothing
const c = p3ToP4Distance * Math.cos(toRadians(angleBeta))
const d = c * Math.tan(toRadians(angleBeta))

let b = (p - arcSectionLength - c - d) / 3
let a = 2 * b

// Adjust the P1 and P2 control points if there's not enough space left
if (preserveSmoothing && p > roundingAndSmoothingBudget) {
const p1ToP3MaxDistance =
roundingAndSmoothingBudget - d - arcSectionLength - c

// Try to maintain some distance between P1 and P2 so the curve wouldn't look weird
const minA = p1ToP3MaxDistance / 6
const maxB = p1ToP3MaxDistance - minA

b = Math.min(b, maxB)
a = p1ToP3MaxDistance - b
p = Math.min(p, roundingAndSmoothingBudget)
}

return {
a,
b,
c,
d,
p,
arcSectionLength,
cornerRadius,
}
}

interface SVGPathInput {
width: number
height: number
topRightPathParams: CornerPathParams
bottomRightPathParams: CornerPathParams
bottomLeftPathParams: CornerPathParams
topLeftPathParams: CornerPathParams
}

export function getSVGPathFromPathParams({
width,
height,
topLeftPathParams,
topRightPathParams,
bottomLeftPathParams,
bottomRightPathParams,
}: SVGPathInput) {
return `
M ${width - topRightPathParams.p} 0
${drawTopRightPath(topRightPathParams)}
L ${width} ${height - bottomRightPathParams.p}
${drawBottomRightPath(bottomRightPathParams)}
L ${bottomLeftPathParams.p} ${height}
${drawBottomLeftPath(bottomLeftPathParams)}
L 0 ${topLeftPathParams.p}
${drawTopLeftPath(topLeftPathParams)}
Z
`
.replace(/[\t\s\n]+/g, ' ')
.trim()
}

function drawTopRightPath({
cornerRadius,
a,
b,
c,
d,
p,
arcSectionLength,
}: CornerPathParams) {
if (cornerRadius) {
return `
c ${a} 0 ${a + b} 0 ${a + b + c} ${d}
a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${arcSectionLength}
c ${d} ${c}
${d} ${b + c}
${d} ${a + b + c}`
} else {
return `l ${p} 0`
}
}

function drawBottomRightPath({
cornerRadius,
a,
b,
c,
d,
p,
arcSectionLength,
}: CornerPathParams) {
if (cornerRadius) {
return `
c 0 ${a}
0 ${a + b}
${-d} ${a + b + c}
a ${cornerRadius} ${cornerRadius} 0 0 1 -${arcSectionLength} ${arcSectionLength}
c ${-c} ${d}
${-(b + c)} ${d}
${-(a + b + c)} ${d}`
} else {
return `l 0 ${p}`
}
}

function drawBottomLeftPath({
cornerRadius,
a,
b,
c,
d,
p,
arcSectionLength,
}: CornerPathParams) {
if (cornerRadius) {
return `
c ${-a} 0
${-(a + b)} 0
${-(a + b + c)} ${-d}
a ${cornerRadius} ${cornerRadius} 0 0 1 -${arcSectionLength} -${arcSectionLength}
c ${-d} ${-c}
${-d} ${-(b + c)}
${-d} ${-(a + b + c)}`
} else {
return `l ${-p} 0`
}
}

function drawTopLeftPath({
cornerRadius,
a,
b,
c,
d,
p,
arcSectionLength,
}: CornerPathParams) {
if (cornerRadius) {
return `
c 0 ${-a}
0 ${-(a + b)}
${d} ${-(a + b + c)}
a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} -${arcSectionLength}
c ${c} ${-d}
${b + c} ${-d}
${a + b + c} ${-d}`
} else {
return `l 0 ${-p}`
}
}

function toRadians(degrees: number) {
return (degrees * Math.PI) / 180
}
Loading

0 comments on commit 58640e3

Please sign in to comment.