fix(canvas): render smoothed corners and effect blend modes

This commit is contained in:
Danila Poyarkov 2026-05-22 22:50:58 +03:00
parent ed8c62cefc
commit 76ee122dee
10 changed files with 238 additions and 12 deletions

View file

@ -15,6 +15,7 @@
- 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 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.
### Performance

View file

@ -3,6 +3,7 @@ import type { Canvas, Paint } from 'canvaskit-wasm'
import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph'
import type { SkiaRenderer } from './renderer'
import { makeSmoothRRectPath, nodeHasSmoothCorners } from './shapes'
export function drawNodeFill(
r: SkiaRenderer,
@ -50,7 +51,11 @@ export function drawNodeFill(
break
}
default:
if (hasRadius) {
if (nodeHasSmoothCorners(node)) {
const path = makeSmoothRRectPath(r, node)
canvas.drawPath(path, r.fillPaint)
path.delete()
} else if (hasRadius) {
canvas.drawRRect(r.makeRRect(node), r.fillPaint)
} else {
canvas.drawRect(rect, r.fillPaint)

View file

@ -10,7 +10,7 @@ import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend'
import { renderBooleanOperation } from './boolean'
import { renderMaskedChildIds } from './masks'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import { nodeHasRadius } from './shapes'
import { makeSmoothRRectPath, nodeHasRadius, nodeHasSmoothCorners } from './shapes'
import {
drawDashedRRectWithSolidCorners,
drawStyledRRectStroke,
@ -162,7 +162,11 @@ function renderChildren(
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
canvas.save()
if (nodeHasRadius(node)) {
if (nodeHasSmoothCorners(node)) {
const clipPath = makeSmoothRRectPath(r, node)
canvas.clipPath(clipPath, r.ck.ClipOp.Intersect, true)
clipPath.delete()
} else if (nodeHasRadius(node)) {
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
} else {
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, true)

View file

@ -2,8 +2,9 @@ import type { Canvas, Path } from 'canvaskit-wasm'
import type { SceneNode } from '#core/scene-graph'
import { figmaBlendModeToSkia } from './blend'
import type { SkiaRenderer } from './renderer'
import { makeNodeShapePath, nodeHasRadius } from './shapes'
import { makeNodeShapePath, makeSmoothRRectPath, nodeHasRadius, nodeHasSmoothCorners } from './shapes'
function resetEffectLayerPaint(r: SkiaRenderer): void {
r.effectLayerPaint.setImageFilter(null)
@ -108,6 +109,10 @@ function drawShadowCutout(
canvas.drawOval(r.ltrb(0, 0, shapeNode.width, shapeNode.height), r.auxFill)
} else if (isPathShape(shapeNode)) {
drawPathShape(r, canvas, shapeNode, shapeHasRadius)
} else if (nodeHasSmoothCorners(shapeNode)) {
const path = makeSmoothRRectPath(r, shapeNode)
canvas.drawPath(path, r.auxFill)
path.delete()
} else if (shapeHasRadius) {
canvas.drawRRect(r.makeRRect(shapeNode), r.auxFill)
} else {
@ -143,6 +148,7 @@ function drawShapeDropShadow(
r.auxFill.setColor(r.color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a))
r.auxFill.setMaskFilter(r.getCachedMaskBlur(effect.radius / 2))
r.auxFill.setImageFilter(null)
r.auxFill.setBlendMode(figmaBlendModeToSkia(r.ck, effect.blendMode))
canvas.save()
let savedLayer = false
try {
@ -163,6 +169,10 @@ function drawShapeDropShadow(
canvas.drawOval(r.ltrb(-sp, -sp, shapeNode.width + sp, shapeNode.height + sp), r.auxFill)
} else if (isPathShape(shapeNode)) {
drawPathShape(r, canvas, shapeNode, shapeHasRadius, sp)
} else if (nodeHasSmoothCorners(shapeNode)) {
const path = makeSmoothRRectPath(r, shapeNode, sp)
canvas.drawPath(path, r.auxFill)
path.delete()
} else if (shapeHasRadius) {
canvas.drawRRect(r.makeRRectWithSpread(shapeNode, sp), r.auxFill)
} else {
@ -206,6 +216,7 @@ function renderDropShadow(
if (shadowShapeChild) drawChildTransform(canvas, shadowShapeChild, effect.offset)
else canvas.translate(effect.offset.x, effect.offset.y)
r.effectLayerPaint.setBlendMode(figmaBlendModeToSkia(r.ck, effect.blendMode))
r.effectLayerPaint.setImageFilter(dropFilter)
canvas.saveLayer(r.effectLayerPaint)
savedLayer = true
@ -303,6 +314,7 @@ function drawShapeInnerShadow(
const shapeNode = shadowShapeChild ?? node
r.auxFill.setColor(r.ck.Color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a))
r.auxFill.setImageFilter(r.getCachedDecalBlur(effect.radius / 2))
r.auxFill.setBlendMode(figmaBlendModeToSkia(r.ck, effect.blendMode))
const shapeRect = shadowShapeChild ? r.ck.LTRBRect(0, 0, shapeNode.width, shapeNode.height) : rect
const shapeHasRadius = shadowShapeChild ? nodeHasRadius(shadowShapeChild) : hasRadius
@ -326,6 +338,10 @@ function drawShapeInnerShadow(
} finally {
path.delete()
}
} else if (nodeHasSmoothCorners(shapeNode)) {
const path = makeSmoothRRectPath(r, shapeNode)
canvas.clipPath(path, r.ck.ClipOp.Intersect, true)
path.delete()
} else if (shapeHasRadius) {
canvas.clipRRect(r.makeRRect(shapeNode), r.ck.ClipOp.Intersect, true)
} else {
@ -368,6 +384,13 @@ function drawShapeInnerShadow(
} finally {
innerPath.delete()
}
} else if (nodeHasSmoothCorners(shapeNode)) {
const innerPath = makeSmoothRRectPath(r, shapeNode, -sp, localOffsetX, localOffsetY)
try {
bigPath.op(innerPath, r.ck.PathOp.Difference)
} finally {
innerPath.delete()
}
} else if (shapeHasRadius) {
const innerPath = new r.ck.Path()
try {
@ -399,6 +422,7 @@ function drawShapeInnerShadow(
} finally {
canvas.restore()
r.auxFill.setImageFilter(null)
r.auxFill.setBlendMode(r.ck.BlendMode.SrcOver)
}
}

View file

@ -17,6 +17,61 @@ export function nodeHasRadius(node: SceneNode): boolean {
)
}
export function nodeHasSmoothCorners(node: SceneNode): boolean {
return !node.independentCorners && node.cornerRadius > 0 && node.cornerSmoothing > 0
}
function clampCornerRadius(width: number, height: number, radius: number): number {
return Math.max(0, Math.min(radius, width / 2, height / 2))
}
export function makeSmoothRRectPath(
r: SkiaRenderer,
node: SceneNode,
spread = 0,
offsetX = 0,
offsetY = 0
): Path {
const path = new r.ck.Path()
const left = offsetX - spread
const top = offsetY - spread
const right = offsetX + node.width + spread
const bottom = offsetY + node.height + spread
const radius = clampCornerRadius(right - left, bottom - top, node.cornerRadius + spread)
if (radius === 0) {
path.addRect(r.ck.LTRBRect(left, top, right, bottom))
return path
}
const smoothing = Math.max(0, Math.min(node.cornerSmoothing, 1))
const exponent = 2 + smoothing * 3
const samples = 12
const addCorner = (cx: number, cy: number, startAngle: number, endAngle: number) => {
for (let i = 1; i <= samples; i++) {
const t = i / samples
const angle = startAngle + (endAngle - startAngle) * t
const cos = Math.cos(angle)
const sin = Math.sin(angle)
const x = cx + Math.sign(cos) * radius * Math.abs(cos) ** (2 / exponent)
const y = cy + Math.sign(sin) * radius * Math.abs(sin) ** (2 / exponent)
path.lineTo(x, y)
}
}
path.moveTo(left + radius, top)
path.lineTo(right - radius, top)
addCorner(right - radius, top + radius, -Math.PI / 2, 0)
path.lineTo(right, bottom - radius)
addCorner(right - radius, bottom - radius, 0, Math.PI / 2)
path.lineTo(left + radius, bottom)
addCorner(left + radius, bottom - radius, Math.PI / 2, Math.PI)
path.lineTo(left, top + radius)
addCorner(left + radius, top + radius, Math.PI, (Math.PI * 3) / 2)
path.close()
return path
}
export function makeNodeShapePath(
r: SkiaRenderer,
node: SceneNode,
@ -43,7 +98,11 @@ export function makeNodeShapePath(
break
}
default:
if (hasRadius) {
if (nodeHasSmoothCorners(node)) {
const smoothPath = makeSmoothRRectPath(r, node)
path.addPath(smoothPath)
smoothPath.delete()
} else if (hasRadius) {
path.addRRect(r.makeRRect(node))
} else {
path.addRect(rect)
@ -157,6 +216,10 @@ export function clipNodeShape(
clipPath.addOval(rect)
canvas.clipPath(clipPath, r.ck.ClipOp.Intersect, true)
clipPath.delete()
} else if (nodeHasSmoothCorners(node)) {
const clipPath = makeSmoothRRectPath(r, node)
canvas.clipPath(clipPath, r.ck.ClipOp.Intersect, true)
clipPath.delete()
} else if (hasRadius) {
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
} else {

View file

@ -4,6 +4,7 @@ import type { SceneNode, Stroke } from '#core/scene-graph'
import type { Color } from '#core/types'
import type { SkiaRenderer } from './renderer'
import { makeSmoothRRectPath, nodeHasSmoothCorners } from './shapes'
export function getStrokeCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
switch (cap) {
@ -148,7 +149,11 @@ export function drawNodeStroke(
break
}
default:
if (hasRadius) {
if (nodeHasSmoothCorners(node)) {
const path = makeSmoothRRectPath(r, node)
canvas.drawPath(path, r.strokePaint)
path.delete()
} else if (hasRadius) {
canvas.drawRRect(r.makeRRect(node), r.strokePaint)
} else {
canvas.drawRect(rect, r.strokePaint)
@ -199,6 +204,11 @@ export function drawRRectStrokeWithAlign(
node: SceneNode,
stroke: Stroke
): void {
if (nodeHasSmoothCorners(node)) {
drawStrokeWithAlign(r, canvas, node, r.ck.LTRBRect(0, 0, node.width, node.height), true, stroke.align)
return
}
if (stroke.align === 'INSIDE') {
canvas.save()
canvas.clipRRect(rrect, r.ck.ClipOp.Intersect, true)

View file

@ -111,7 +111,7 @@ Figma's design documentation groups features into these areas:
| Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. |
| Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. |
| Sections | ✅ | ✅ | ✅ | ✅ | ✅ | Section rendering and title pills are OpenPencil-specific approximations. |
| Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii supported; corner smoothing is not rendered. |
| Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii and smoothed uniform corners render; independent-corner smoothing remains approximate. |
| Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. |
| Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps/joins render but are not fully exposed in UI. |
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
@ -131,7 +131,7 @@ Figma's design documentation groups features into these areas:
| Image fills | ✅ | ✅ | ◐ | ✅ | ✅ | Fill/fit/crop/tile support exists; exact Figma image transform parity is partial. |
| Pattern fills/strokes | — | — | — | — | — | Figma pattern fills are not currently modeled. |
| Video/GIF/media fills | — | — | — | — | — | No video playback or media layer support. |
| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node and fill blend modes; effect blend modes and Figma isolation edge cases are still partial. |
| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node, fill, and common shadow effect blend modes; Figma isolation edge cases remain partial. |
| Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. |
| Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. |
| Stroke caps / joins / miter limit | ✅ | ✅ | ◐ | ✅ | ✅ | Renderer/export support exists; inspector controls are limited. |
@ -203,9 +203,9 @@ 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:
1. **Blend modes** — node, fill, and effect blend modes should be applied in CanvasKit rendering, not just stored/exported.
2. **Masks** — implement Figma mask stacks and `maskType` behavior beyond frame clipping.
3. **Corner smoothing** — render Figma's smooth/squircle corners instead of ordinary rounded rectangles.
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.
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.
5. **Font variations** — apply variable-font axes from imported Figma metadata.
6. **Boolean operation import** — keep Figma `BOOLEAN_OPERATION` nodes as boolean operations where possible instead of importing them as vectors.

View file

@ -0,0 +1,90 @@
import { describe, expect, mock, test } from 'bun:test'
import type { Canvas } from 'canvaskit-wasm'
import { drawNodeFill } from '#core/canvas/fills'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { makeSmoothRRectPath } from '#core/canvas/shapes'
import { SceneGraph } from '#core/scene-graph'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
function createRenderer() {
const paths: Array<{
addRect: ReturnType<typeof mock>
moveTo: ReturnType<typeof mock>
lineTo: ReturnType<typeof mock>
close: ReturnType<typeof mock>
delete: ReturnType<typeof mock>
}> = []
class MockPath {
addRect = mock(() => undefined)
moveTo = mock(() => undefined)
lineTo = mock(() => undefined)
close = mock(() => undefined)
delete = mock(() => undefined)
constructor() {
paths.push(this)
}
}
const renderer = {
ck: {
Path: MockPath,
LTRBRect: mock((l, t, r, b) => new Float32Array([l, t, r, b]))
},
fillPaint: {}
} as SkiaRenderer
return { renderer, paths }
}
function createCanvas() {
return {
drawPath: mock(() => undefined),
drawRRect: mock(() => undefined),
drawRect: mock(() => undefined)
}
}
describe('canvas corner smoothing', () => {
test('builds a superellipse-style path for smoothed rectangular corners', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 120,
height: 80,
cornerRadius: 24,
cornerSmoothing: 0.75
})
const { renderer, paths } = createRenderer()
makeSmoothRRectPath(renderer, node).delete()
expect(paths).toHaveLength(1)
expect(paths[0].moveTo).toHaveBeenCalledWith(24, 0)
expect(paths[0].lineTo).toHaveBeenCalledWith(96, 0)
expect(paths[0].lineTo).toHaveBeenCalledTimes(52)
expect(paths[0].close).toHaveBeenCalled()
})
test('draws smoothed rectangle fills as paths instead of regular rrects', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 120,
height: 80,
cornerRadius: 24,
cornerSmoothing: 0.75
})
const { renderer } = createRenderer()
const canvas = createCanvas()
drawNodeFill(renderer, canvas as Canvas, node, new Float32Array([0, 0, 120, 80]), true)
expect(canvas.drawPath).toHaveBeenCalled()
expect(canvas.drawRRect).not.toHaveBeenCalled()
expect(canvas.drawRect).not.toHaveBeenCalled()
})
})

View file

@ -32,7 +32,7 @@ export function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaR
PathOp: { Difference: 0, Union: 1 },
StrokeJoin: { Round: 0 },
Matrix: { translated: mock(() => new Float32Array(9)) },
BlendMode: { SrcOver: 0, SrcIn: 1, DstOut: 2 },
BlendMode: { SrcOver: 0, SrcIn: 1, DstOut: 2, Screen: 3, Multiply: 4 },
ColorType: { RGBA_8888: 0 },
AlphaType: { Premul: 0, Unpremul: 1 },
ColorSpace: { SRGB: 0 },

View file

@ -72,6 +72,35 @@ describe('Renderer handles all effect types (Behavioral)', () => {
expect(r.auxFill.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.DstOut)
})
test('applies effect blend modes to rendered shadow paint', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()
const node: Partial<SceneNode> = {
type: 'RECTANGLE',
width: 100,
height: 100,
fills: [],
childIds: [],
strokeGeometry: [],
effects: [
{
type: 'DROP_SHADOW',
visible: true,
color: { r: 0, g: 0, b: 0, a: 0.5 },
offset: { x: 5, y: 5 },
radius: 10,
spread: 0,
blendMode: 'SCREEN'
}
]
}
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
expect(r.auxFill.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.Screen)
expect(r.auxFill.setBlendMode).toHaveBeenLastCalledWith(r.ck.BlendMode.SrcOver)
})
test('handles INNER_SHADOW', () => {
const r = createMockRenderer()
const canvas = createMockCanvas()