fix(canvas): render Figma-style corner smoothing

This commit is contained in:
Danila Poyarkov 2026-05-22 23:05:24 +03:00
parent 76ee122dee
commit 98616c8610
5 changed files with 331 additions and 36 deletions

View file

@ -15,7 +15,7 @@
- Fix CanvasKit loading outside the browser when project paths contain spaces. - Fix CanvasKit loading outside the browser when project paths contain spaces.
- Render imported Figma layer and fill blend modes such as multiply, screen, overlay, difference, hue, saturation, color, and luminosity. - Render imported Figma layer and fill blend modes such as multiply, screen, overlay, difference, hue, saturation, color, and luminosity.
- Render common imported Figma mask stacks so visible layers above a mask are clipped by the mask shape. - Render common imported Figma mask stacks so visible layers above a mask are clipped by the mask shape.
- Render smoothed rectangle corners and effect blend modes from imported Figma files. - Render Figma-style smoothed rectangle corners, including independent corner radii, and effect blend modes from imported Figma files.
### Performance ### Performance

View file

@ -18,11 +18,180 @@ export function nodeHasRadius(node: SceneNode): boolean {
} }
export function nodeHasSmoothCorners(node: SceneNode): boolean { export function nodeHasSmoothCorners(node: SceneNode): boolean {
return !node.independentCorners && node.cornerRadius > 0 && node.cornerSmoothing > 0 if (!(node.cornerSmoothing > 0)) return false
if (node.independentCorners) {
return (
node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0
)
}
return node.cornerRadius > 0
} }
function clampCornerRadius(width: number, height: number, radius: number): number { type SmoothCornerKey = 'topLeft' | 'topRight' | 'bottomRight' | 'bottomLeft'
return Math.max(0, Math.min(radius, width / 2, height / 2))
type SmoothCorner = {
radius: number
budget: number
}
type SmoothCornerPathParams = {
a: number
b: number
c: number
d: number
p: number
radius: number
arcSectionLength: number
}
function smoothCornerRadii(node: SceneNode, width: number, height: number, spread: number): Record<SmoothCornerKey, SmoothCorner> {
const radius = (value: number) => Math.max(0, value + spread)
const radii: Record<SmoothCornerKey, number> = node.independentCorners
? {
topLeft: radius(node.topLeftRadius),
topRight: radius(node.topRightRadius),
bottomRight: radius(node.bottomRightRadius),
bottomLeft: radius(node.bottomLeftRadius)
}
: {
topLeft: radius(node.cornerRadius),
topRight: radius(node.cornerRadius),
bottomRight: radius(node.cornerRadius),
bottomLeft: radius(node.cornerRadius)
}
if (radii.topLeft === radii.topRight && radii.topRight === radii.bottomRight && radii.bottomRight === radii.bottomLeft) {
const budget = Math.min(width, height) / 2
const clampedRadius = Math.min(radii.topLeft, budget)
return {
topLeft: { radius: clampedRadius, budget },
topRight: { radius: clampedRadius, budget },
bottomRight: { radius: clampedRadius, budget },
bottomLeft: { radius: clampedRadius, budget }
}
}
const budgets: Record<SmoothCornerKey, number> = {
topLeft: -1,
topRight: -1,
bottomRight: -1,
bottomLeft: -1
}
const adjacentByCorner: Record<SmoothCornerKey, Array<{ corner: SmoothCornerKey; sideLength: number }>> = {
topLeft: [
{ corner: 'topRight', sideLength: width },
{ corner: 'bottomLeft', sideLength: height }
],
topRight: [
{ corner: 'topLeft', sideLength: width },
{ corner: 'bottomRight', sideLength: height }
],
bottomRight: [
{ corner: 'bottomLeft', sideLength: width },
{ corner: 'topRight', sideLength: height }
],
bottomLeft: [
{ corner: 'bottomRight', sideLength: width },
{ corner: 'topLeft', sideLength: height }
]
}
for (const corner of (Object.keys(radii) as SmoothCornerKey[]).sort((a, b) => radii[b] - radii[a])) {
const cornerRadius = radii[corner]
const budget = Math.min(
...adjacentByCorner[corner].map((adjacent) => {
const adjacentRadius = radii[adjacent.corner]
if (cornerRadius === 0 && adjacentRadius === 0) return 0
if (budgets[adjacent.corner] >= 0) return adjacent.sideLength - budgets[adjacent.corner]
return (cornerRadius / (cornerRadius + adjacentRadius)) * adjacent.sideLength
})
)
budgets[corner] = budget
radii[corner] = Math.min(cornerRadius, budget)
}
return {
topLeft: { radius: radii.topLeft, budget: budgets.topLeft },
topRight: { radius: radii.topRight, budget: budgets.topRight },
bottomRight: { radius: radii.bottomRight, budget: budgets.bottomRight },
bottomLeft: { radius: radii.bottomLeft, budget: budgets.bottomLeft }
}
}
function degreesToRadians(degrees: number): number {
return (degrees * Math.PI) / 180
}
function smoothCornerPathParams(corner: SmoothCorner, smoothing: number): SmoothCornerPathParams {
let cornerSmoothing = smoothing
let p = (1 + cornerSmoothing) * corner.radius
if (corner.radius > 0) {
const maxSmoothing = corner.budget / corner.radius - 1
cornerSmoothing = Math.min(cornerSmoothing, maxSmoothing)
p = Math.min(p, corner.budget)
}
const arcMeasure = 90 * (1 - cornerSmoothing)
const arcSectionLength = Math.sin(degreesToRadians(arcMeasure / 2)) * corner.radius * Math.sqrt(2)
const angleAlpha = (90 - arcMeasure) / 2
const p3ToP4Distance = corner.radius * Math.tan(degreesToRadians(angleAlpha / 2))
const angleBeta = 45 * cornerSmoothing
const c = p3ToP4Distance * Math.cos(degreesToRadians(angleBeta))
const d = c * Math.tan(degreesToRadians(angleBeta))
const b = (p - arcSectionLength - c - d) / 3
return {
a: 2 * b,
b,
c,
d,
p,
radius: corner.radius,
arcSectionLength
}
}
function drawTopRightSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
if (corner.radius === 0) {
path.lineTo(x + corner.p, y)
return
}
path.cubicTo(x + corner.a, y, x + corner.a + corner.b, y, x + corner.a + corner.b + corner.c, y + corner.d)
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x + corner.p - corner.d, y + corner.p - corner.a - corner.b - corner.c)
path.cubicTo(x + corner.p, y + corner.p - corner.a - corner.b, x + corner.p, y + corner.p - corner.a, x + corner.p, y + corner.p)
}
function drawBottomRightSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
if (corner.radius === 0) {
path.lineTo(x, y + corner.p)
return
}
path.cubicTo(x, y + corner.a, x, y + corner.a + corner.b, x - corner.d, y + corner.a + corner.b + corner.c)
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x - corner.p + corner.a + corner.b + corner.c, y + corner.p - corner.d)
path.cubicTo(x - corner.p + corner.a + corner.b, y + corner.p, x - corner.p + corner.a, y + corner.p, x - corner.p, y + corner.p)
}
function drawBottomLeftSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
if (corner.radius === 0) {
path.lineTo(x - corner.p, y)
return
}
path.cubicTo(x - corner.a, y, x - corner.a - corner.b, y, x - corner.a - corner.b - corner.c, y - corner.d)
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x - corner.p + corner.d, y - corner.p + corner.a + corner.b + corner.c)
path.cubicTo(x - corner.p, y - corner.p + corner.a + corner.b, x - corner.p, y - corner.p + corner.a, x - corner.p, y - corner.p)
}
function drawTopLeftSmoothCorner(path: Path, corner: SmoothCornerPathParams, x: number, y: number) {
if (corner.radius === 0) {
path.lineTo(x, y - corner.p)
return
}
path.cubicTo(x, y - corner.a, x, y - corner.a - corner.b, x + corner.d, y - corner.a - corner.b - corner.c)
path.arcToRotated(corner.radius, corner.radius, 0, true, false, x + corner.p - corner.a - corner.b - corner.c, y - corner.p + corner.d)
path.cubicTo(x + corner.p - corner.a - corner.b, y - corner.p, x + corner.p - corner.a, y - corner.p, x + corner.p, y - corner.p)
} }
export function makeSmoothRRectPath( export function makeSmoothRRectPath(
@ -37,37 +206,38 @@ export function makeSmoothRRectPath(
const top = offsetY - spread const top = offsetY - spread
const right = offsetX + node.width + spread const right = offsetX + node.width + spread
const bottom = offsetY + node.height + spread const bottom = offsetY + node.height + spread
const radius = clampCornerRadius(right - left, bottom - top, node.cornerRadius + spread) const width = right - left
const height = bottom - top
if (radius === 0) { if (width <= 0 || height <= 0) {
path.addRect(r.ck.LTRBRect(left, top, right, bottom)) path.addRect(r.ck.LTRBRect(left, top, Math.max(left, right), Math.max(top, bottom)))
return path return path
} }
const smoothing = Math.max(0, Math.min(node.cornerSmoothing, 1)) const smoothing = Math.max(0, Math.min(node.cornerSmoothing, 1))
const exponent = 2 + smoothing * 3 const corners = smoothCornerRadii(node, width, height, spread)
const samples = 12 const topLeftCorner = smoothCornerPathParams(corners.topLeft, smoothing)
const addCorner = (cx: number, cy: number, startAngle: number, endAngle: number) => { const topRightCorner = smoothCornerPathParams(corners.topRight, smoothing)
for (let i = 1; i <= samples; i++) { const bottomRightCorner = smoothCornerPathParams(corners.bottomRight, smoothing)
const t = i / samples const bottomLeftCorner = smoothCornerPathParams(corners.bottomLeft, smoothing)
const angle = startAngle + (endAngle - startAngle) * t
const cos = Math.cos(angle) if (
const sin = Math.sin(angle) topLeftCorner.radius === 0 &&
const x = cx + Math.sign(cos) * radius * Math.abs(cos) ** (2 / exponent) topRightCorner.radius === 0 &&
const y = cy + Math.sign(sin) * radius * Math.abs(sin) ** (2 / exponent) bottomRightCorner.radius === 0 &&
path.lineTo(x, y) bottomLeftCorner.radius === 0
} ) {
path.addRect(r.ck.LTRBRect(left, top, right, bottom))
return path
} }
path.moveTo(left + radius, top) path.moveTo(right - topRightCorner.p, top)
path.lineTo(right - radius, top) drawTopRightSmoothCorner(path, topRightCorner, right - topRightCorner.p, top)
addCorner(right - radius, top + radius, -Math.PI / 2, 0) path.lineTo(right, bottom - bottomRightCorner.p)
path.lineTo(right, bottom - radius) drawBottomRightSmoothCorner(path, bottomRightCorner, right, bottom - bottomRightCorner.p)
addCorner(right - radius, bottom - radius, 0, Math.PI / 2) path.lineTo(left + bottomLeftCorner.p, bottom)
path.lineTo(left + radius, bottom) drawBottomLeftSmoothCorner(path, bottomLeftCorner, left + bottomLeftCorner.p, bottom)
addCorner(left + radius, bottom - radius, Math.PI / 2, Math.PI) path.lineTo(left, top + topLeftCorner.p)
path.lineTo(left, top + radius) drawTopLeftSmoothCorner(path, topLeftCorner, left, top + topLeftCorner.p)
addCorner(left + radius, top + radius, Math.PI, (Math.PI * 3) / 2)
path.close() path.close()
return path return path
} }

View file

@ -111,7 +111,7 @@ Figma's design documentation groups features into these areas:
| Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. | | Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. |
| Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. | | Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. |
| Sections | ✅ | ✅ | ✅ | ✅ | ✅ | Section rendering and title pills are OpenPencil-specific approximations. | | Sections | ✅ | ✅ | ✅ | ✅ | ✅ | Section rendering and title pills are OpenPencil-specific approximations. |
| Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii and smoothed uniform corners render; independent-corner smoothing remains approximate. | | Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii and smoothed corners render for fills, strokes, clips, masks, and effects. |
| Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. | | Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. |
| Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps/joins render but are not fully exposed in UI. | | Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps/joins render but are not fully exposed in UI. |
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. | | Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
@ -204,7 +204,7 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not
These are parsed or visible in Figma docs and most likely to cause visible differences in real design files: These are parsed or visible in Figma docs and most likely to cause visible differences in real design files:
1. **Masks** — extend common sibling mask stacks with luminance masks and more exact `maskType` behavior. 1. **Masks** — extend common sibling mask stacks with luminance masks and more exact `maskType` behavior.
2. **Corner smoothing**improve the current smoothed uniform-corner approximation for independent corners and stroke edge cases. 2. **Corner smoothing**compare smoothed-corner output against Figma fixtures and tune remaining edge cases.
3. **Pattern fills** — render imported Figma pattern/image tiling semantics beyond current image scale modes. 3. **Pattern fills** — render imported Figma pattern/image tiling semantics beyond current image scale modes.
4. **Pattern fills/strokes** — support Figma pattern fills and their transforms. 4. **Pattern fills/strokes** — support Figma pattern fills and their transforms.
5. **Font variations** — apply variable-font axes from imported Figma metadata. 5. **Font variations** — apply variable-font axes from imported Figma metadata.

View file

@ -5,7 +5,7 @@ import type { EditorStore } from '@/app/editor/session'
export function createEffectsSection(store: EditorStore) { export function createEffectsSection(store: EditorStore) {
const { graph } = store const { graph } = store
const effectsSectionId = store.createShape('SECTION', 60, 840, 920, 640) const effectsSectionId = store.createShape('SECTION', 60, 840, 920, 780)
graph.updateNode(effectsSectionId, { graph.updateNode(effectsSectionId, {
name: 'Effects', name: 'Effects',
fills: [solid(DEMO_COLORS.white)] fills: [solid(DEMO_COLORS.white)]
@ -328,4 +328,100 @@ export function createEffectsSection(store: EditorStore) {
rotation: -20, rotation: -20,
fills: [solid(DEMO_COLORS.teal)] fills: [solid(DEMO_COLORS.teal)]
}) })
const smoothingLabel = store.createShape('TEXT', 32, 644, 280, 20, effectsSectionId)
graph.updateNode(smoothingLabel, {
name: 'Label',
text: 'Corner smoothing & effect blends',
fontSize: 13,
fontWeight: 600,
fills: [solid(DEMO_COLORS.gray500)]
})
const smoothingCard = store.createShape('FRAME', 32, 676, 256, 104, effectsSectionId)
graph.updateNode(smoothingCard, {
name: 'Smooth Corner Comparison',
cornerRadius: 16,
fills: [solid(DEMO_COLORS.gray50)],
strokes: thinStroke(DEMO_COLORS.gray200)
})
const regularCorner = store.createShape('RECTANGLE', 24, 18, 58, 58, smoothingCard)
graph.updateNode(regularCorner, {
name: 'Regular Radius',
cornerRadius: 18,
fills: [solid(DEMO_COLORS.blue)]
})
const smoothCorner = store.createShape('RECTANGLE', 102, 18, 58, 58, smoothingCard)
graph.updateNode(smoothCorner, {
name: 'Smoothed Radius',
cornerRadius: 18,
cornerSmoothing: 0.9,
fills: [solid(DEMO_COLORS.purple)]
})
const cornerLabel = store.createShape('TEXT', 176, 26, 56, 36, smoothingCard)
graph.updateNode(cornerLabel, {
name: 'Label',
text: 'Radius\n+ smooth',
fontSize: 12,
fontWeight: 600,
lineHeight: 16,
fills: [solid(DEMO_COLORS.gray500)]
})
const independentCard = store.createShape('FRAME', 312, 676, 204, 104, effectsSectionId)
graph.updateNode(independentCard, {
name: 'Independent Smooth Corners',
cornerRadius: 28,
cornerSmoothing: 1,
independentCorners: true,
topLeftRadius: 36,
topRightRadius: 10,
bottomRightRadius: 36,
bottomLeftRadius: 10,
fills: [
gradient([
{ color: DEMO_COLORS.teal, position: 0 },
{ color: DEMO_COLORS.blue, position: 1 }
])
],
effects: [dropShadow(0, 8, 20, 0, { r: 0.08, g: 0.73, b: 0.73, a: 0.24 })]
})
const independentText = store.createShape('TEXT', 20, 34, 164, 24, independentCard)
graph.updateNode(independentText, {
name: 'Label',
text: 'Independent corners',
fontSize: 15,
fontWeight: 700,
fills: [solid(DEMO_COLORS.white)]
})
const effectBlendCard = store.createShape('FRAME', 540, 676, 300, 104, effectsSectionId)
graph.updateNode(effectBlendCard, {
name: 'Effect Blend Card',
cornerRadius: 18,
fills: [solid({ r: 0.08, g: 0.1, b: 0.18, a: 1 })],
clipsContent: true
})
const glowBase = store.createShape('ELLIPSE', 22, 14, 76, 76, effectBlendCard)
graph.updateNode(glowBase, {
name: 'Glow Base',
fills: [solid({ r: 0.24, g: 0.46, b: 1, a: 1 })]
})
const glowShape = store.createShape('RECTANGLE', 70, 22, 96, 60, effectBlendCard)
graph.updateNode(glowShape, {
name: 'Screen Shadow Blend',
cornerRadius: 22,
cornerSmoothing: 0.85,
fills: [solid({ r: 0.58, g: 0.27, b: 0.95, a: 0.7 })],
effects: [{ ...dropShadow(0, 0, 28, 0, { r: 0.56, g: 0.33, b: 1, a: 0.72 }), blendMode: 'SCREEN' }]
})
const effectBlendText = store.createShape('TEXT', 184, 34, 88, 34, effectBlendCard)
graph.updateNode(effectBlendText, {
name: 'Label',
text: 'Screen\nshadow',
fontSize: 13,
fontWeight: 700,
lineHeight: 17,
fills: [solid(DEMO_COLORS.white)]
})
} }

View file

@ -15,6 +15,8 @@ function createRenderer() {
addRect: ReturnType<typeof mock> addRect: ReturnType<typeof mock>
moveTo: ReturnType<typeof mock> moveTo: ReturnType<typeof mock>
lineTo: ReturnType<typeof mock> lineTo: ReturnType<typeof mock>
cubicTo: ReturnType<typeof mock>
arcToRotated: ReturnType<typeof mock>
close: ReturnType<typeof mock> close: ReturnType<typeof mock>
delete: ReturnType<typeof mock> delete: ReturnType<typeof mock>
}> = [] }> = []
@ -23,6 +25,8 @@ function createRenderer() {
addRect = mock(() => undefined) addRect = mock(() => undefined)
moveTo = mock(() => undefined) moveTo = mock(() => undefined)
lineTo = mock(() => undefined) lineTo = mock(() => undefined)
cubicTo = mock(() => undefined)
arcToRotated = mock(() => undefined)
close = mock(() => undefined) close = mock(() => undefined)
delete = mock(() => undefined) delete = mock(() => undefined)
@ -51,7 +55,7 @@ function createCanvas() {
} }
describe('canvas corner smoothing', () => { describe('canvas corner smoothing', () => {
test('builds a superellipse-style path for smoothed rectangular corners', () => { test('builds cubic paths for smoothed rectangular corners', () => {
const graph = new SceneGraph() const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 120, width: 120,
@ -64,12 +68,37 @@ describe('canvas corner smoothing', () => {
makeSmoothRRectPath(renderer, node).delete() makeSmoothRRectPath(renderer, node).delete()
expect(paths).toHaveLength(1) expect(paths).toHaveLength(1)
expect(paths[0].moveTo).toHaveBeenCalledWith(24, 0) expect(paths[0].moveTo).toHaveBeenCalledWith(80, 0)
expect(paths[0].lineTo).toHaveBeenCalledWith(96, 0) expect(paths[0].lineTo).toHaveBeenCalledWith(120, 40)
expect(paths[0].lineTo).toHaveBeenCalledTimes(52) expect(paths[0].arcToRotated).toHaveBeenCalledTimes(4)
expect(paths[0].cubicTo).toHaveBeenCalledTimes(8)
expect(paths[0].lineTo).toHaveBeenCalledTimes(3)
expect(paths[0].close).toHaveBeenCalled() expect(paths[0].close).toHaveBeenCalled()
}) })
test('supports independent smoothed corner radii', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 120,
height: 80,
independentCorners: true,
topLeftRadius: 28,
topRightRadius: 12,
bottomRightRadius: 32,
bottomLeftRadius: 0,
cornerSmoothing: 1
})
const { renderer, paths } = createRenderer()
makeSmoothRRectPath(renderer, node).delete()
expect(paths).toHaveLength(1)
expect(paths[0].moveTo).toHaveBeenCalledWith(98.18181818181819, 0)
expect(paths[0].arcToRotated).toHaveBeenCalledTimes(3)
expect(paths[0].cubicTo).toHaveBeenCalledTimes(6)
expect(paths[0].lineTo).toHaveBeenCalledWith(0, 80)
})
test('draws smoothed rectangle fills as paths instead of regular rrects', () => { test('draws smoothed rectangle fills as paths instead of regular rrects', () => {
const graph = new SceneGraph() const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { const node = graph.createNode('RECTANGLE', pageId(graph), {