diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9056d4c..8755db272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/src/canvas/fills.ts b/packages/core/src/canvas/fills.ts index 9eb2d0647..d99b42f85 100644 --- a/packages/core/src/canvas/fills.ts +++ b/packages/core/src/canvas/fills.ts @@ -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) diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 34c7e2039..a009cbf15 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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) diff --git a/packages/core/src/canvas/shadows.ts b/packages/core/src/canvas/shadows.ts index 32b882b45..53c84a654 100644 --- a/packages/core/src/canvas/shadows.ts +++ b/packages/core/src/canvas/shadows.ts @@ -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) } } diff --git a/packages/core/src/canvas/shapes.ts b/packages/core/src/canvas/shapes.ts index e4286ca0e..4085d6b33 100644 --- a/packages/core/src/canvas/shapes.ts +++ b/packages/core/src/canvas/shapes.ts @@ -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 { diff --git a/packages/core/src/canvas/strokes.ts b/packages/core/src/canvas/strokes.ts index 128bc843d..d4e01af24 100644 --- a/packages/core/src/canvas/strokes.ts +++ b/packages/core/src/canvas/strokes.ts @@ -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) diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 80390df4b..8742069e5 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -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. diff --git a/tests/engine/render/canvas/corner-smoothing.test.ts b/tests/engine/render/canvas/corner-smoothing.test.ts new file mode 100644 index 000000000..2e7681d6b --- /dev/null +++ b/tests/engine/render/canvas/corner-smoothing.test.ts @@ -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 + moveTo: ReturnType + lineTo: ReturnType + close: ReturnType + delete: ReturnType + }> = [] + + 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() + }) +}) diff --git a/tests/engine/render/canvas/effects/helpers.ts b/tests/engine/render/canvas/effects/helpers.ts index 210bca072..fd8af66a5 100644 --- a/tests/engine/render/canvas/effects/helpers.ts +++ b/tests/engine/render/canvas/effects/helpers.ts @@ -32,7 +32,7 @@ export function createMockRenderer(overrides: Partial = {}): 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 }, diff --git a/tests/engine/render/canvas/effects/types.test.ts b/tests/engine/render/canvas/effects/types.test.ts index 73a6479d1..29d93fbfe 100644 --- a/tests/engine/render/canvas/effects/types.test.ts +++ b/tests/engine/render/canvas/effects/types.test.ts @@ -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 = { + 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()