fix(canvas): render Figma blend modes
This commit is contained in:
parent
da65cb6dc7
commit
8e1d5d40e7
|
|
@ -13,6 +13,7 @@
|
|||
- Fix accidental duplicate creation when Alt-clicking without dragging.
|
||||
- Fix MCP startup in the browser.
|
||||
- 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.
|
||||
|
||||
### Performance
|
||||
|
||||
|
|
|
|||
44
packages/core/src/canvas/blend.ts
Normal file
44
packages/core/src/canvas/blend.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { BlendMode as SkBlendMode, CanvasKit } from 'canvaskit-wasm'
|
||||
|
||||
import type { BlendMode } from '#core/scene-graph'
|
||||
|
||||
export function figmaBlendModeToSkia(ck: CanvasKit, mode?: BlendMode): SkBlendMode {
|
||||
switch (mode) {
|
||||
case 'DARKEN':
|
||||
return ck.BlendMode.Darken
|
||||
case 'MULTIPLY':
|
||||
return ck.BlendMode.Multiply
|
||||
case 'COLOR_BURN':
|
||||
return ck.BlendMode.ColorBurn
|
||||
case 'LIGHTEN':
|
||||
return ck.BlendMode.Lighten
|
||||
case 'SCREEN':
|
||||
return ck.BlendMode.Screen
|
||||
case 'COLOR_DODGE':
|
||||
return ck.BlendMode.ColorDodge
|
||||
case 'OVERLAY':
|
||||
return ck.BlendMode.Overlay
|
||||
case 'SOFT_LIGHT':
|
||||
return ck.BlendMode.SoftLight
|
||||
case 'HARD_LIGHT':
|
||||
return ck.BlendMode.HardLight
|
||||
case 'DIFFERENCE':
|
||||
return ck.BlendMode.Difference
|
||||
case 'EXCLUSION':
|
||||
return ck.BlendMode.Exclusion
|
||||
case 'HUE':
|
||||
return ck.BlendMode.Hue
|
||||
case 'SATURATION':
|
||||
return ck.BlendMode.Saturation
|
||||
case 'COLOR':
|
||||
return ck.BlendMode.Color
|
||||
case 'LUMINOSITY':
|
||||
return ck.BlendMode.Luminosity
|
||||
default:
|
||||
return ck.BlendMode.SrcOver
|
||||
}
|
||||
}
|
||||
|
||||
export function needsIsolatedBlendLayer(mode?: BlendMode): boolean {
|
||||
return mode !== undefined && mode !== 'NORMAL' && mode !== 'PASS_THROUGH'
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph'
|
|||
import type { Color } from '#core/types'
|
||||
import { vectorNetworkToCenterlinePath } from '#core/vector'
|
||||
|
||||
import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend'
|
||||
import { renderBooleanOperation } from './boolean'
|
||||
import type { SkiaRenderer, RenderOverlays } from './renderer'
|
||||
import { nodeHasRadius } from './shapes'
|
||||
|
|
@ -27,11 +28,12 @@ function drawVisibleFills(
|
|||
if (!fill.visible) continue
|
||||
if (!r.applyFill(fill, node, graph, fi)) continue
|
||||
r.fillPaint.setAlphaf(fill.opacity)
|
||||
r.fillPaint.setBlendMode(figmaBlendModeToSkia(r.ck, fill.blendMode))
|
||||
draw(fill)
|
||||
r.fillPaint.setShader(null)
|
||||
r.fillPaint.setBlendMode(r.ck.BlendMode.SrcOver)
|
||||
}
|
||||
}
|
||||
|
||||
function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean {
|
||||
const canCull =
|
||||
node.childIds.length === 0 ||
|
||||
|
|
@ -75,7 +77,6 @@ function applyNodeTransforms(
|
|||
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
function renderNodeContent(
|
||||
r: SkiaRenderer,
|
||||
canvas: Canvas,
|
||||
|
|
@ -134,7 +135,6 @@ function renderChildren(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderNode(
|
||||
r: SkiaRenderer,
|
||||
canvas: Canvas,
|
||||
|
|
@ -163,8 +163,10 @@ export function renderNode(
|
|||
canvas.save()
|
||||
canvas.translate(node.x, node.y)
|
||||
|
||||
if (node.opacity < 1) {
|
||||
const needsNodeLayer = node.opacity < 1 || needsIsolatedBlendLayer(node.blendMode)
|
||||
if (needsNodeLayer) {
|
||||
r.opacityPaint.setAlphaf(node.opacity)
|
||||
r.opacityPaint.setBlendMode(figmaBlendModeToSkia(r.ck, node.blendMode))
|
||||
canvas.saveLayer(r.opacityPaint)
|
||||
}
|
||||
|
||||
|
|
@ -192,8 +194,10 @@ export function renderNode(
|
|||
r.effectLayerPaint.setColorFilter(null)
|
||||
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
|
||||
}
|
||||
if (node.opacity < 1) {
|
||||
if (needsNodeLayer) {
|
||||
canvas.restore()
|
||||
r.opacityPaint.setAlphaf(1)
|
||||
r.opacityPaint.setBlendMode(r.ck.BlendMode.SrcOver)
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 | ✅ | ◐ | — | ✅ | ✅ | Parsed/exported, SVG export maps some modes, but Canvas rendering does not fully apply node/fill/effect blend modes. |
|
||||
| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node and fill blend modes; effect blend modes and Figma isolation edge cases are still 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. |
|
||||
|
|
|
|||
155
tests/engine/render/canvas/blend.test.ts
Normal file
155
tests/engine/render/canvas/blend.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { describe, expect, mock, test } from 'bun:test'
|
||||
import type { Canvas } from 'canvaskit-wasm'
|
||||
|
||||
import { figmaBlendModeToSkia } from '#core/canvas/blend'
|
||||
import type { SkiaRenderer } from '#core/canvas/renderer'
|
||||
import { renderNode, renderSection } from '#core/canvas/scene'
|
||||
import { SceneGraph } from '#core/scene-graph'
|
||||
import type { BlendMode, Fill, SceneNode } from '#core/scene-graph'
|
||||
|
||||
function pageId(graph: SceneGraph) {
|
||||
return graph.getPages()[0].id
|
||||
}
|
||||
|
||||
const blendModes = {
|
||||
SrcOver: 'SrcOver',
|
||||
Darken: 'Darken',
|
||||
Multiply: 'Multiply',
|
||||
ColorBurn: 'ColorBurn',
|
||||
Lighten: 'Lighten',
|
||||
Screen: 'Screen',
|
||||
ColorDodge: 'ColorDodge',
|
||||
Overlay: 'Overlay',
|
||||
SoftLight: 'SoftLight',
|
||||
HardLight: 'HardLight',
|
||||
Difference: 'Difference',
|
||||
Exclusion: 'Exclusion',
|
||||
Hue: 'Hue',
|
||||
Saturation: 'Saturation',
|
||||
Color: 'Color',
|
||||
Luminosity: 'Luminosity'
|
||||
}
|
||||
|
||||
function createCanvas() {
|
||||
return {
|
||||
save: mock(() => undefined),
|
||||
restore: mock(() => undefined),
|
||||
translate: mock(() => undefined),
|
||||
saveLayer: mock(() => undefined),
|
||||
drawRRect: mock(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function createRenderer(overrides: Partial<SkiaRenderer> = {}) {
|
||||
return {
|
||||
_nodeCount: 0,
|
||||
_culledCount: 0,
|
||||
worldViewport: { x: -100, y: -100, w: 1000, h: 1000 },
|
||||
ck: {
|
||||
BlendMode: blendModes,
|
||||
LTRBRect: mock(() => new Float32Array(4)),
|
||||
RRectXY: mock(() => new Float32Array(12))
|
||||
},
|
||||
opacityPaint: {
|
||||
setAlphaf: mock(() => undefined),
|
||||
setBlendMode: mock(() => undefined)
|
||||
},
|
||||
effectLayerPaint: {
|
||||
setImageFilter: mock(() => undefined),
|
||||
setColorFilter: mock(() => undefined),
|
||||
setBlendMode: mock(() => undefined)
|
||||
},
|
||||
fillPaint: {
|
||||
setAlphaf: mock(() => undefined),
|
||||
setBlendMode: mock(() => undefined),
|
||||
setShader: mock(() => undefined)
|
||||
},
|
||||
getCachedBlur: mock(() => null),
|
||||
applyFill: mock(() => true),
|
||||
makeRRect: mock(() => new Float32Array(12)),
|
||||
renderShape: mock(() => undefined),
|
||||
renderSection: mock(() => undefined),
|
||||
renderComponentSet: mock(() => undefined),
|
||||
renderNode(canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) {
|
||||
renderNode(this as SkiaRenderer, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY)
|
||||
},
|
||||
...overrides
|
||||
} as SkiaRenderer
|
||||
}
|
||||
|
||||
function calls(fn: ReturnType<typeof mock>): unknown[][] {
|
||||
return (fn as { mock: { calls: unknown[][] } }).mock.calls
|
||||
}
|
||||
|
||||
describe('canvas blend modes', () => {
|
||||
test('maps Figma blend modes to CanvasKit blend modes', () => {
|
||||
const ck = { BlendMode: blendModes } as SkiaRenderer['ck']
|
||||
const cases: Array<[BlendMode, string]> = [
|
||||
['NORMAL', 'SrcOver'],
|
||||
['PASS_THROUGH', 'SrcOver'],
|
||||
['DARKEN', 'Darken'],
|
||||
['MULTIPLY', 'Multiply'],
|
||||
['COLOR_BURN', 'ColorBurn'],
|
||||
['LIGHTEN', 'Lighten'],
|
||||
['SCREEN', 'Screen'],
|
||||
['COLOR_DODGE', 'ColorDodge'],
|
||||
['OVERLAY', 'Overlay'],
|
||||
['SOFT_LIGHT', 'SoftLight'],
|
||||
['HARD_LIGHT', 'HardLight'],
|
||||
['DIFFERENCE', 'Difference'],
|
||||
['EXCLUSION', 'Exclusion'],
|
||||
['HUE', 'Hue'],
|
||||
['SATURATION', 'Saturation'],
|
||||
['COLOR', 'Color'],
|
||||
['LUMINOSITY', 'Luminosity']
|
||||
]
|
||||
|
||||
for (const [figmaMode, skiaMode] of cases) {
|
||||
expect(figmaBlendModeToSkia(ck, figmaMode)).toBe(skiaMode)
|
||||
}
|
||||
})
|
||||
|
||||
test('isolates node content when node blend mode needs compositing', () => {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
blendMode: 'MULTIPLY'
|
||||
})
|
||||
const renderer = createRenderer()
|
||||
const canvas = createCanvas()
|
||||
|
||||
renderNode(renderer, canvas as Canvas, graph, node.id, {})
|
||||
|
||||
expect(renderer.opacityPaint.setAlphaf).toHaveBeenCalledWith(1)
|
||||
expect(renderer.opacityPaint.setBlendMode).toHaveBeenCalledWith('Multiply')
|
||||
expect(canvas.saveLayer).toHaveBeenCalledWith(renderer.opacityPaint)
|
||||
expect(renderer.renderShape).toHaveBeenCalled()
|
||||
expect(renderer.opacityPaint.setBlendMode).toHaveBeenLastCalledWith('SrcOver')
|
||||
})
|
||||
|
||||
test('applies fill blend modes per fill and resets shared paint', () => {
|
||||
const graph = new SceneGraph()
|
||||
const fill: Fill = {
|
||||
type: 'SOLID',
|
||||
color: { r: 1, g: 0, b: 0, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
blendMode: 'SCREEN'
|
||||
}
|
||||
const node = graph.createNode('SECTION', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
fills: [fill]
|
||||
}) as Extract<SceneNode, { type: 'SECTION' }>
|
||||
const renderer = createRenderer()
|
||||
const canvas = createCanvas()
|
||||
|
||||
renderSection(renderer, canvas as Canvas, node, graph)
|
||||
|
||||
expect(calls(renderer.fillPaint.setBlendMode as ReturnType<typeof mock>)).toEqual([
|
||||
['Screen'],
|
||||
['SrcOver']
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue