From 0ce57ea0fa2ce175e3538661ddab35c7ea149dd2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 22 May 2026 21:05:35 +0300 Subject: [PATCH] fix(canvas): render Figma mask stacks --- CHANGELOG.md | 1 + packages/core/src/canvas/masks.ts | 47 ++++++++++++ packages/core/src/canvas/scene.ts | 52 +++++++++++-- packages/docs/development/roadmap.md | 2 +- tests/engine/render/canvas/masks.test.ts | 97 ++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/canvas/masks.ts create mode 100644 tests/engine/render/canvas/masks.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0631198b2..7a9056d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 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. +- Render common imported Figma mask stacks so visible layers above a mask are clipped by the mask shape. ### Performance diff --git a/packages/core/src/canvas/masks.ts b/packages/core/src/canvas/masks.ts new file mode 100644 index 000000000..283eafcba --- /dev/null +++ b/packages/core/src/canvas/masks.ts @@ -0,0 +1,47 @@ +import type { Canvas } from 'canvaskit-wasm' + +import type { SkiaRenderer } from './renderer' + +function resetMaskPaint(r: SkiaRenderer): void { + r.effectLayerPaint.setImageFilter(null) + r.effectLayerPaint.setColorFilter(null) + r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver) +} + +export function renderMaskedChildIds( + r: SkiaRenderer, + canvas: Canvas, + childIds: string[], + isVisibleMask: (childId: string) => boolean, + renderChild: (childId: string) => void, + renderMask: (childId: string) => void +): void { + for (let index = 0; index < childIds.length; index++) { + const childId = childIds[index] + if (!isVisibleMask(childId)) { + renderChild(childId) + continue + } + + const start = index + 1 + let end = start + while (end < childIds.length && !isVisibleMask(childIds[end])) end++ + if (start === end) continue + + resetMaskPaint(r) + canvas.save() + canvas.saveLayer(r.effectLayerPaint) + for (let maskedIndex = start; maskedIndex < end; maskedIndex++) renderChild(childIds[maskedIndex]) + + resetMaskPaint(r) + r.effectLayerPaint.setBlendMode(r.ck.BlendMode.DstIn) + canvas.saveLayer(r.effectLayerPaint) + renderMask(childId) + canvas.restore() + + canvas.restore() + canvas.restore() + resetMaskPaint(r) + index = end - 1 + } +} diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index e1d5d0e8a..34c7e2039 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- scene dispatch stays together while shape domains live in sibling modules */ import type { Canvas, Path } from 'canvaskit-wasm' import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '#core/constants' @@ -7,6 +8,7 @@ import { vectorNetworkToCenterlinePath } from '#core/vector' import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend' import { renderBooleanOperation } from './boolean' +import { renderMaskedChildIds } from './masks' import type { SkiaRenderer, RenderOverlays } from './renderer' import { nodeHasRadius } from './shapes' import { @@ -106,6 +108,46 @@ function renderNodeContent( } } +function renderMaskNodeContent( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + node: SceneNode, + nodeId: string, + overlays: RenderOverlays +): void { + canvas.save() + canvas.translate(node.x, node.y) + applyNodeTransforms(r, canvas, node, nodeId, overlays) + renderNodeContent(r, canvas, graph, node, nodeId, {}) + canvas.restore() +} + +function renderChildIds( + r: SkiaRenderer, + canvas: Canvas, + graph: SceneGraph, + childIds: string[], + overlays: RenderOverlays, + absX: number, + absY: number +): void { + renderMaskedChildIds( + r, + canvas, + childIds, + (childId) => { + const child = graph.getNode(childId) + return !!child?.visible && child.isMask + }, + (childId) => r.renderNode(canvas, graph, childId, overlays, absX, absY), + (childId) => { + const child = graph.getNode(childId) + if (child) renderMaskNodeContent(r, canvas, graph, child, childId, overlays) + } + ) +} + function renderChildren( r: SkiaRenderer, canvas: Canvas, @@ -125,14 +167,10 @@ function renderChildren( } else { canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, true) } - for (const childId of node.childIds) { - r.renderNode(canvas, graph, childId, overlays, absX, absY) - } + renderChildIds(r, canvas, graph, node.childIds, overlays, absX, absY) canvas.restore() } else { - for (const childId of node.childIds) { - r.renderNode(canvas, graph, childId, overlays, absX, absY) - } + renderChildIds(r, canvas, graph, node.childIds, overlays, absX, absY) } } export function renderNode( @@ -145,7 +183,7 @@ export function renderNode( parentAbsY = 0 ): void { const node = graph.getNode(nodeId) - if (!node || !node.visible) return + if (!node || !node.visible || node.isMask) return // Hide the node being edited in node-edit mode (overlay draws it live) if (overlays.nodeEditState?.nodeId === nodeId) return diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 2b358791a..80390df4b 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -139,7 +139,7 @@ Figma's design documentation groups features into these areas: | Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. | | Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. | | Corner smoothing | ✅ | — | — | ✅ | ✅ | Stored/exported but rendered as ordinary rounded rectangles. | -| Masks | ✅ | ◐ | — | ✅ | ✅ | `isMask`/`maskType` exist; true Figma mask stack/type semantics are incomplete. | +| Masks | ✅ | ◐ | — | ✅ | ✅ | Common sibling mask stacks render; luminance masks, UI controls, and edge-case Figma semantics remain incomplete. | | Auto layout: vertical/horizontal | ✅ | ✅ | ✅ | ✅ | ✅ | Yoga-backed layout. | | Auto layout: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. | | Auto layout: grid | ✅ | ◐ | ◐ | ✅ | ✅ | CSS-grid-like support is partial. | diff --git a/tests/engine/render/canvas/masks.test.ts b/tests/engine/render/canvas/masks.test.ts new file mode 100644 index 000000000..ccf425977 --- /dev/null +++ b/tests/engine/render/canvas/masks.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { Canvas } from 'canvaskit-wasm' + +import type { SkiaRenderer } from '#core/canvas/renderer' +import { renderNode } from '#core/canvas/scene' +import { SceneGraph } from '#core/scene-graph' + +function pageId(graph: SceneGraph) { + return graph.getPages()[0].id +} + +function createCanvas() { + return { + save: mock(() => undefined), + restore: mock(() => undefined), + translate: mock(() => undefined), + rotate: mock(() => undefined), + scale: mock(() => undefined), + saveLayer: mock(() => undefined), + clipRect: mock(() => undefined), + clipRRect: mock(() => undefined) + } +} + +function createRenderer() { + const rendered: string[] = [] + const renderer = { + _nodeCount: 0, + _culledCount: 0, + worldViewport: { x: -100, y: -100, w: 1000, h: 1000 }, + ck: { + BlendMode: { SrcOver: 'SrcOver', DstIn: 'DstIn' }, + LTRBRect: mock(() => new Float32Array(4)), + ClipOp: { Intersect: 'Intersect' } + }, + opacityPaint: { + setAlphaf: mock(() => undefined), + setBlendMode: mock(() => undefined) + }, + effectLayerPaint: { + setImageFilter: mock(() => undefined), + setColorFilter: mock(() => undefined), + setBlendMode: mock(() => undefined) + }, + getCachedBlur: mock(() => null), + renderShape: mock((_canvas: Canvas, node) => { + rendered.push(node.id) + }), + renderSection: mock((_canvas: Canvas, node) => { + rendered.push(node.id) + }), + renderComponentSet: mock((_canvas: Canvas, node) => { + rendered.push(node.id) + }), + renderNode(canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) { + renderNode(this as SkiaRenderer, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) + } + } + return { renderer: renderer as SkiaRenderer, rendered } +} + +describe('canvas masks', () => { + test('uses a visible mask node to clip following siblings', () => { + const graph = new SceneGraph() + const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 }) + const below = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 }) + const mask = graph.createNode('ELLIPSE', frame.id, { + width: 100, + height: 100, + isMask: true + }) + const clipped = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 }) + const { renderer, rendered } = createRenderer() + const canvas = createCanvas() + + renderNode(renderer, canvas as Canvas, graph, frame.id, {}) + + expect(rendered).toEqual([frame.id, below.id, clipped.id, mask.id]) + expect(renderer.effectLayerPaint.setBlendMode).toHaveBeenCalledWith('DstIn') + expect(renderer.effectLayerPaint.setBlendMode).toHaveBeenLastCalledWith('SrcOver') + expect(canvas.saveLayer).toHaveBeenCalledTimes(2) + }) + + test('does not draw mask nodes as ordinary layers', () => { + const graph = new SceneGraph() + const mask = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + isMask: true + }) + const { renderer, rendered } = createRenderer() + + renderNode(renderer, createCanvas() as Canvas, graph, mask.id, {}) + + expect(rendered).toEqual([]) + }) +})