fix(canvas): support luminance masks and tile transforms

This commit is contained in:
Danila Poyarkov 2026-05-22 23:44:10 +03:00
parent f8db73949e
commit 3dc0904466
9 changed files with 255 additions and 58 deletions

View file

@ -14,8 +14,9 @@
- 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.
- Render common imported Figma mask stacks so visible layers above alpha, vector, or luminance masks are clipped by the mask shape.
- Render Figma-style smoothed rectangle corners, including independent corner radii, and effect blend modes from imported Figma files.
- Improve imported tiled image fills by applying Figma image transforms when repeating image patterns.
### Performance

View file

@ -192,6 +192,43 @@ export function applyGradientFill(
}
}
export function makeImageFillLocalMatrix(
r: SkiaRenderer,
fill: Fill,
node: SceneNode,
imgW: number,
imgH: number
) {
const scaleMode = fill.imageScaleMode ?? 'FILL'
if (scaleMode === 'TILE' && !fill.imageTransform) return r.ck.Matrix.identity()
let sx: number, sy: number, sw: number, sh: number
if ((scaleMode === 'CROP' || scaleMode === 'TILE') && fill.imageTransform) {
const t = fill.imageTransform
sx = t.m02 * imgW
sy = t.m12 * imgH
sw = t.m00 * imgW
sh = t.m11 * imgH
} else if (scaleMode === 'FIT') {
const scale = Math.min(node.width / imgW, node.height / imgH)
sw = imgW
sh = imgH
sx = -(node.width / scale - imgW) / 2
sy = -(node.height / scale - imgH) / 2
} else {
const scale = Math.max(node.width / imgW, node.height / imgH)
sw = node.width / scale
sh = node.height / scale
sx = (imgW - sw) / 2
sy = (imgH - sh) / 2
}
return r.ck.Matrix.multiply(
r.ck.Matrix.scaled(node.width / sw, node.height / sh),
r.ck.Matrix.translated(-sx, -sy)
)
}
export function applyImageFill(
r: SkiaRenderer,
fill: Fill,
@ -215,42 +252,26 @@ export function applyImageFill(
const imgH = img.height()
const scaleMode = fill.imageScaleMode ?? 'FILL'
const localMatrix = makeImageFillLocalMatrix(r, fill, node, imgW, imgH)
if (scaleMode === 'TILE') {
const shader = img.makeShaderCubic(r.ck.TileMode.Repeat, r.ck.TileMode.Repeat, 1 / 3, 1 / 3)
const shader = img.makeShaderCubic(
r.ck.TileMode.Repeat,
r.ck.TileMode.Repeat,
1 / 3,
1 / 3,
localMatrix
)
r.fillPaint.setShader(shader)
return true
}
let sx: number, sy: number, sw: number, sh: number
if (scaleMode === 'CROP' && fill.imageTransform) {
const t = fill.imageTransform
sx = t.m02 * imgW
sy = t.m12 * imgH
sw = t.m00 * imgW
sh = t.m11 * imgH
} else if (scaleMode === 'FIT') {
const scale = Math.min(node.width / imgW, node.height / imgH)
sw = imgW
sh = imgH
sx = -(node.width / scale - imgW) / 2
sy = -(node.height / scale - imgH) / 2
} else {
const scale = Math.max(node.width / imgW, node.height / imgH)
sw = node.width / scale
sh = node.height / scale
sx = (imgW - sw) / 2
sy = (imgH - sh) / 2
}
const shader = img.makeShaderOptions(
r.ck.TileMode.Clamp,
r.ck.TileMode.Clamp,
r.ck.FilterMode.Linear,
r.ck.MipmapMode.Linear,
r.ck.Matrix.multiply(
r.ck.Matrix.scaled(node.width / sw, node.height / sh),
r.ck.Matrix.translated(-sx, -sy)
)
localMatrix
)
r.fillPaint.setShader(shader)
return true

View file

@ -1,5 +1,7 @@
import type { Canvas } from 'canvaskit-wasm'
import type { MaskType } from '#core/scene-graph'
import type { SkiaRenderer } from './renderer'
function resetMaskPaint(r: SkiaRenderer): void {
@ -12,36 +14,44 @@ export function renderMaskedChildIds(
r: SkiaRenderer,
canvas: Canvas,
childIds: string[],
isVisibleMask: (childId: string) => boolean,
getVisibleMaskType: (childId: string) => MaskType | null,
renderChild: (childId: string) => void,
renderMask: (childId: string) => void
): void {
for (let index = 0; index < childIds.length; index++) {
const childId = childIds[index]
if (!isVisibleMask(childId)) {
const maskType = getVisibleMaskType(childId)
if (!maskType) {
renderChild(childId)
continue
}
const start = index + 1
let end = start
while (end < childIds.length && !isVisibleMask(childIds[end])) end++
while (end < childIds.length && !getVisibleMaskType(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])
const lumaFilter = maskType === 'LUMINANCE' ? r.ck.ColorFilter.MakeLuma() : null
try {
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()
resetMaskPaint(r)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.DstIn)
if (lumaFilter) r.effectLayerPaint.setColorFilter(lumaFilter)
canvas.saveLayer(r.effectLayerPaint)
renderMask(childId)
canvas.restore()
canvas.restore()
canvas.restore()
resetMaskPaint(r)
canvas.restore()
canvas.restore()
} finally {
resetMaskPaint(r)
lumaFilter?.delete()
}
index = end - 1
}
}

View file

@ -138,7 +138,7 @@ function renderChildIds(
childIds,
(childId) => {
const child = graph.getNode(childId)
return !!child?.visible && child.isMask
return child?.visible && child.isMask ? child.maskType : null
},
(childId) => r.renderNode(canvas, graph, childId, overlays, absX, absY),
(childId) => {

View file

@ -717,7 +717,7 @@ function preserveFigmaPayloadBlobs(value: unknown, blobs: Uint8Array[]): unknown
return result
}
const FIGMA_RAW_NODE_FIELD_KEYS = [
export const FIGMA_RAW_NODE_FIELD_KEYS = [
'styleIdForFill',
'styleIdForStrokeFill',
'styleIdForText',

View file

@ -128,8 +128,8 @@ Figma's design documentation groups features into these areas:
| FigJam sticky/code/widget/stamp/media/highlight/washi tape | — | — | — | — | — | Not first-class scene nodes. Unsupported types generally fall back or are skipped. |
| Solid fills | ✅ | ✅ | ✅ | ✅ | ✅ | Color variables supported for common fill cases. |
| Gradients | ✅ | ✅ | ✅ | ✅ | ✅ | Linear/radial/angular/diamond support; Figma edge cases may differ. |
| 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. |
| Image fills | ✅ | ✅ | ◐ | ✅ | ✅ | Fill/fit/crop/tile support exists; imported tile transforms are applied, but exact Figma parity is still partial. |
| Pattern fills/strokes | — | ◐ | — | — | — | Figma pattern paint objects are not first-class yet; image tile transforms cover a subset of pattern-like imported fills. |
| Video/GIF/media fills | — | — | — | — | — | No video playback or media layer support. |
| 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. |
@ -138,8 +138,8 @@ Figma's design documentation groups features into these areas:
| Effects: shadows and blurs | ✅ | ✅ | ✅ | ✅ | ✅ | `showShadowBehindNode` is rendered but not exposed in UI. |
| 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 | ✅ | ◐ | — | ✅ | ✅ | Common sibling mask stacks render; luminance masks, UI controls, and edge-case Figma semantics remain incomplete. |
| Corner smoothing | ✅ | ✅ | — | ✅ | ✅ | Figma-style smoothed corners render for common uniform and independent-radius rectangles; exact parity still needs broader fixture tuning. |
| Masks | ✅ | ◐ | — | ✅ | ✅ | Common sibling alpha/vector/luminance mask stacks render; 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. |
@ -203,15 +203,14 @@ 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. **Masks** — extend common sibling mask stacks with luminance masks and more exact `maskType` behavior.
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.
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.
7. **Layout grids and guides** — render/edit page guides and Figma layout grids, or clearly keep them round-trip-only.
8. **Full component property and slot workflows** — support authoring, not just preserving imported payloads.
9. **Prototype metadata** — start by preserving prototype flows/connections even before building playback.
1. **Masks** — tune multi-mask stacks and exact Figma edge cases beyond the common alpha/vector/luminance path.
2. **Corner smoothing** — expand Figma fixture comparisons and tune remaining stroke/effect edge cases.
3. **Pattern fills/strokes** — support Figma pattern paint objects and transforms beyond image tile fills.
4. **Font variations** — apply variable-font axes from imported Figma metadata.
5. **Boolean operation import** — keep Figma `BOOLEAN_OPERATION` nodes as boolean operations where possible instead of importing them as vectors.
6. **Layout grids and guides** — render/edit page guides and Figma layout grids, or clearly keep them round-trip-only.
7. **Full component property and slot workflows** — support authoring, not just preserving imported payloads.
8. **Prototype metadata** — start by preserving prototype flows/connections even before building playback.
## Code map

View file

@ -0,0 +1,92 @@
import { describe, expect, test } from 'bun:test'
import { FIGMA_RAW_NODE_FIELD_KEYS } from '#core/kiwi/fig/node-change/convert'
const RAW_FIELD_COVERAGE = {
rendered: [
'backgroundColor',
'borderBottomWeight',
'borderLeftWeight',
'borderRightWeight',
'borderStrokeWeightsIndependent',
'borderTopWeight',
'derivedTextData',
'effects',
'fillGeometry',
'fillPaints',
'fontName',
'fontSize',
'fontVariations',
'letterSpacing',
'lineHeight',
'miterLimit',
'strokeGeometry',
'strokeJoin',
'strokePaints',
'strokeWeight',
'textAutoResize',
'textData',
'textTracking',
'vectorData'
],
uiEditable: [
'componentPropDefs',
'fontSize',
'letterSpacing',
'lineHeight',
'strokeJoin',
'strokeWeight',
'textAutoResize'
],
toolEditable: [
'componentPropDefs',
'effects',
'fillPaints',
'fontSize',
'letterSpacing',
'lineHeight',
'strokeJoin',
'strokePaints',
'strokeWeight',
'textAutoResize',
'vectorData'
],
roundTripOnly: [
'componentPropRefs',
'editInfo',
'fontVersion',
'guides',
'isStateGroup',
'pageType',
'parameterConsumptionMap',
'sortPosition',
'sourceLibraryKey',
'stateGroupPropertyValueOrders',
'styleIdForEffect',
'styleIdForFill',
'styleIdForGrid',
'styleIdForStrokeFill',
'styleIdForText',
'textExplicitLayoutVersion',
'textUserLayoutVersion',
'userFacingVersion',
'variableConsumptionMap',
'variantPropSpecs',
'version'
],
unsupportedUnknown: []
} as const satisfies Record<string, readonly string[]>
describe('Figma raw field coverage', () => {
test('classifies every preserved raw node field', () => {
const knownKeys = new Set(FIGMA_RAW_NODE_FIELD_KEYS)
const classifiedKeys = new Set(Object.values(RAW_FIELD_COVERAGE).flat())
expect(new Set(FIGMA_RAW_NODE_FIELD_KEYS).size).toBe(FIGMA_RAW_NODE_FIELD_KEYS.length)
expect([...classifiedKeys].sort()).toEqual([...knownKeys].sort())
})
test('keeps unsupported raw field bucket explicit', () => {
expect(RAW_FIELD_COVERAGE.unsupportedUnknown).toEqual([])
})
})

View file

@ -0,0 +1,49 @@
import { describe, expect, mock, test } from 'bun:test'
import { makeImageFillLocalMatrix } from '#core/canvas/fills'
import type { SkiaRenderer } from '#core/canvas/renderer'
import type { Fill, SceneNode } from '#core/scene-graph'
function createRenderer() {
return {
ck: {
Matrix: {
identity: mock(() => ['identity']),
multiply: mock((...matrices) => ['multiply', ...matrices]),
scaled: mock((x, y) => ['scaled', x, y]),
translated: mock((x, y) => ['translated', x, y])
}
}
} as SkiaRenderer
}
const node = {
width: 120,
height: 80
} as SceneNode
describe('canvas image fills', () => {
test('keeps untransformed tile fills in image pixel space', () => {
const renderer = createRenderer()
const fill = { type: 'IMAGE', imageScaleMode: 'TILE' } as Fill
const matrix = makeImageFillLocalMatrix(renderer, fill, node, 24, 16)
expect(matrix).toEqual(['identity'])
expect(renderer.ck.Matrix.identity).toHaveBeenCalled()
expect(renderer.ck.Matrix.scaled).not.toHaveBeenCalled()
})
test('uses imported tile transforms for patterned image fills', () => {
const renderer = createRenderer()
const fill = {
type: 'IMAGE',
imageScaleMode: 'TILE',
imageTransform: { m00: 0.5, m01: 0, m02: 0.25, m10: 0, m11: 0.25, m12: 0.5 }
} as Fill
const matrix = makeImageFillLocalMatrix(renderer, fill, node, 40, 40)
expect(matrix).toEqual(['multiply', ['scaled', 6, 8], ['translated', -10, -20]])
})
})

View file

@ -1,4 +1,5 @@
import { describe, expect, mock, test } from 'bun:test'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from '#core/canvas/renderer'
@ -31,7 +32,10 @@ function createRenderer() {
ck: {
BlendMode: { SrcOver: 'SrcOver', DstIn: 'DstIn' },
LTRBRect: mock(() => new Float32Array(4)),
ClipOp: { Intersect: 'Intersect' }
ClipOp: { Intersect: 'Intersect' },
ColorFilter: {
MakeLuma: mock(() => ({ delete: mock(() => undefined) }))
}
},
opacityPaint: {
setAlphaf: mock(() => undefined),
@ -81,6 +85,27 @@ describe('canvas masks', () => {
expect(canvas.saveLayer).toHaveBeenCalledTimes(2)
})
test('applies luminance masks through a luma color filter', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 })
const mask = graph.createNode('RECTANGLE', frame.id, {
width: 100,
height: 100,
isMask: true,
maskType: 'LUMINANCE'
})
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, clipped.id, mask.id])
expect(renderer.ck.ColorFilter.MakeLuma).toHaveBeenCalled()
expect(renderer.effectLayerPaint.setColorFilter).toHaveBeenCalledWith(expect.any(Object))
expect(renderer.effectLayerPaint.setColorFilter).toHaveBeenLastCalledWith(null)
})
test('does not draw mask nodes as ordinary layers', () => {
const graph = new SceneGraph()
const mask = graph.createNode('RECTANGLE', pageId(graph), {