fix(canvas): render Figma pattern fills

This commit is contained in:
Danila Poyarkov 2026-05-26 17:33:45 +03:00
parent 63f391d694
commit d208cc326e
5 changed files with 170 additions and 10 deletions

View file

@ -1,6 +1,7 @@
import type { Canvas, Paint } from 'canvaskit-wasm'
import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph'
import type { Rect } from '#core/types'
import type { SkiaRenderer } from './renderer'
import { makeSmoothRRectPath, nodeHasSmoothCorners } from './shapes'
@ -87,6 +88,8 @@ export function applyFill(
return r.applyImageFill(fill, node, graph)
}
if (fill.type === 'PATTERN' && applyPatternFill(r, fill, node, graph)) return true
if (fill.type === 'PATTERN' || fill.type === 'NOISE' || fill.type === 'CUSTOM') {
const c = r.resolveFillColor(fill, fillIndex, node, graph)
r.fillPaint.setColor(r.ck.Color4f(c.r, c.g, c.b, c.a))
@ -96,6 +99,60 @@ export function applyFill(
return false
}
function patternTileRect(source: SceneNode, fill: Fill): Rect {
const spacing = fill.patternSpacing ?? { x: fill.spacing ?? 0, y: fill.spacing ?? 0 }
return {
x: 0,
y: 0,
width: source.width * (1 + spacing.x),
height: source.height * (1 + spacing.y)
}
}
function recordPatternSource(r: SkiaRenderer, source: SceneNode, graph: SceneGraph) {
const bounds = r.ck.LTRBRect(0, 0, source.width, source.height)
const recorder = new r.ck.PictureRecorder()
const canvas = recorder.beginRecording(bounds)
const rect = r.ck.LTRBRect(0, 0, source.width, source.height)
const hasRadius = nodeHasSmoothCorners(source) || source.cornerRadius > 0
for (const sourceFill of source.fills.filter((item) => item.visible)) {
if (sourceFill.type === 'PATTERN' && sourceFill.sourceNodeId === source.id) continue
if (!applyFill(r, sourceFill, source, graph)) continue
drawNodeFill(r, canvas, source, rect, hasRadius, sourceFill)
}
const picture = recorder.finishRecordingAsPicture()
recorder.delete()
return picture
}
function applyPatternFill(
r: SkiaRenderer,
fill: Fill,
node: SceneNode,
graph: SceneGraph
): boolean {
const sourceId = fill.sourceNodeId
if (!sourceId || sourceId === node.id) return false
const source = graph.getNode(sourceId)
if (!source || source.width <= 0 || source.height <= 0) return false
const picture = recordPatternSource(r, source, graph)
const tile = patternTileRect(source, fill)
const tileRect = r.ck.LTRBRect(tile.x, tile.y, tile.x + tile.width, tile.y + tile.height)
const shader = picture.makeShader(
r.ck.TileMode.Repeat,
r.ck.TileMode.Repeat,
r.ck.FilterMode.Linear,
undefined,
tileRect
)
r.fillPaint.setShader(shader)
picture.delete()
return true
}
function makeGradientLocalMatrix(
r: SkiaRenderer,
width: number,

View file

@ -129,7 +129,7 @@ Figma's design documentation groups features into these areas:
| 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; imported crop/tile affine transforms are applied, but exact Figma parity is still partial. |
| Pattern / noise / custom fills | ✅ | ◐ | — | ✅ | — | Schema metadata imports/exports and paints render with a solid fallback color; first-class pattern/noise/custom rendering is still missing. Transformed image tile fills cover only a subset of pattern-like imported fills. |
| Pattern / noise / custom fills | ✅ | ◐ | — | ✅ | — | Schema metadata imports/exports; Figma pattern fills with a referenced source node render as repeated source tiles. Noise/custom paints still render with a solid fallback pending real payload samples. |
| Video/GIF/media fills | ↩ | — | — | ↩ | — | Kiwi schema includes media paint/export enums, but OpenPencil has no video/GIF 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. |
@ -205,7 +205,7 @@ These are parsed or visible in Figma docs and most likely to cause visible diffe
1. **Masks** — tune remaining exact Figma stack semantics beyond common alpha/vector/luminance and consecutive-mask paths. `tests/fixtures/figma-oracles/masks.json` records live Figma API values for alpha, vector, and luminance masks.
2. **Corner smoothing** — expand Figma fixture comparisons and tune remaining stroke/effect edge cases.
3. **Pattern/noise/custom fills**replace the current solid-color fallback with Figma-oracle rendering for schema-level paint objects and transforms beyond image tile fills. `tests/fixtures/figma-oracles/pattern-noise-custom-paints.json` records that the plugin runtime rejects authoring these paint types and current fixtures do not contain them, so this remains blocked on a Figma-authored sample.
3. **Pattern/noise/custom fills**tune first-class pattern rendering for hexagonal tiles, scale, and alignment. `tests/fixtures/figma-oracles/pattern-noise-custom-paints.json` captures a real async Figma `PATTERN` payload; real `NOISE` / `CUSTOM` payloads remain blocked on Figma-authored samples.
4. **Variable-font and rich text fixtures** — broaden real-file coverage for variable axes, derived text data, leading trim, decoration style, semantic font metadata, and raw OpenType feature metadata; `tests/fixtures/figma-oracles/rich-text-decoration.json` captures the first live Figma rich-text oracle.
5. **Boolean operation editing** — improve inspector/tooling workflows for imported boolean-operation nodes.
6. **Layout grids and guides** — render/edit page guides and Figma layout grids, or clearly keep them round-trip-only.

View file

@ -1,7 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import type { Vector } from '#core/types'
interface PatternOracleFill {
type: string
sourceNodeId: string
tileType: string
spacing: Vector
horizontalAlignment: string
verticalAlignment: string
}
interface PaintOracle {
pattern: {
source: { id: string }
target: { fills: PatternOracleFill[] }
}
pluginRuntimeCreation: Record<string, { ok: boolean; message: string }>
currentFileFillTypes: Record<string, number>
localFigFixtureFillTypes: Record<string, Record<string, number>>
@ -15,15 +30,29 @@ function readOracle(): PaintOracle {
}
describe('Figma pattern/noise/custom paint oracle availability', () => {
test('records that real paint payloads are still blocked on Figma-authored samples', () => {
test('records the live Figma pattern paint payload', () => {
const patternFill = readOracle().pattern.target.fills[0]
expect(patternFill?.type).toBe('PATTERN')
expect(patternFill?.sourceNodeId).toBe(readOracle().pattern.source.id)
expect(patternFill?.tileType).toBe('RECTANGULAR')
expect(patternFill?.spacing.x).toBe(0.25)
expect(patternFill?.spacing.y).toBeCloseTo(0.4)
expect(patternFill?.horizontalAlignment).toBe('CENTER')
expect(patternFill?.verticalAlignment).toBe('CENTER')
})
test('records that noise and custom payloads are still blocked on Figma-authored samples', () => {
const oracle = readOracle()
for (const type of ['PATTERN', 'NOISE', 'CUSTOM']) {
expect(oracle.pluginRuntimeCreation.PATTERN_DIRECT_FILLS_ASSIGNMENT?.ok).toBe(false)
for (const type of ['NOISE', 'CUSTOM']) {
expect(oracle.pluginRuntimeCreation[type]?.ok).toBe(false)
expect(oracle.currentFileFillTypes[type]).toBeUndefined()
for (const counts of Object.values(oracle.localFigFixtureFillTypes)) {
expect(counts[type]).toBeUndefined()
}
}
expect(oracle.status).toContain('blocked on a Figma-authored sample')
expect(oracle.status).toContain('NOISE and CUSTOM')
})
})

View file

@ -6,21 +6,69 @@ import { SceneGraph } from '#core/scene-graph'
import type { Fill, SceneNode } from '#core/scene-graph'
function createRenderer() {
const picture = {
makeShader: mock(() => 'pattern-shader'),
delete: mock(() => undefined)
}
const recorder = {
beginRecording: mock(() => ({
drawRect: mock(() => undefined),
drawRRect: mock(() => undefined)
})),
finishRecordingAsPicture: mock(() => picture),
delete: mock(() => undefined)
}
return {
fillPaint: {
setShader: mock(() => undefined),
setColor: mock(() => undefined)
},
ck: {
Color4f: mock((r, g, b, a) => ['color', r, g, b, a])
Color4f: mock((r, g, b, a) => ['color', r, g, b, a]),
FilterMode: { Linear: 'linear' },
LTRBRect: mock((left, top, right, bottom) => [left, top, right, bottom]),
PictureRecorder: mock(() => recorder),
TileMode: { Repeat: 'repeat' }
},
resolveFillColor: mock((fill: Fill) => fill.color)
resolveFillColor: mock((fill: Fill) => fill.color),
makeRRect: mock(() => 'rrect')
} as SkiaRenderer
}
const node = { id: '1:2', width: 100, height: 100 } as SceneNode
describe('schema fill fallback rendering', () => {
test('renders pattern fills from referenced source nodes', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const source = graph.createNode('RECTANGLE', page.id, {
width: 10,
height: 10,
fills: [
{
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true
}
]
})
const renderer = createRenderer()
const fill: Fill = {
type: 'PATTERN',
sourceNodeId: source.id,
patternSpacing: { x: 0.25, y: 0.4 },
color: { r: 0.2, g: 0.3, b: 0.4, a: 0.8 },
opacity: 1,
visible: true
}
expect(applyFill(renderer, fill, node, graph)).toBe(true)
expect(renderer.fillPaint.setShader).toHaveBeenLastCalledWith('pattern-shader')
expect(renderer.fillPaint.setColor).not.toHaveBeenCalledWith(['color', 0.2, 0.3, 0.4, 0.8])
})
test.each(['PATTERN', 'NOISE', 'CUSTOM'] as const)(
'renders %s fills as solid fallback',
(type) => {

View file

@ -4,10 +4,36 @@
"fileKey": "jSxlQDCrjsvqEQq17IWEcC",
"captured": "2026-05-22"
},
"pattern": {
"page": "OpenPencil pattern oracle 2026-05-22",
"source": {
"id": "296519:2244",
"name": "Pattern source dot"
},
"target": {
"id": "296519:2245",
"name": "Pattern fill target",
"fills": [
{
"type": "PATTERN",
"visible": true,
"opacity": 1,
"blendMode": "NORMAL",
"sourceNodeId": "296519:2244",
"tileType": "RECTANGULAR",
"scalingFactor": 1,
"spacing": { "x": 0.25, "y": 0.4000000059604645 },
"horizontalAlignment": "CENTER",
"verticalAlignment": "CENTER"
}
]
},
"note": "Captured with setFillsAsync; direct node.fills assignment rejects PATTERN."
},
"pluginRuntimeCreation": {
"PATTERN": {
"PATTERN_DIRECT_FILLS_ASSIGNMENT": {
"ok": false,
"message": "in set_fills: Property \"fills\" failed validation: Invalid discriminator value. Expected 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND' | 'IMAGE' | 'VIDEO' at [0].type"
"message": "Directly assigning PATTERN to node.fills fails validation; Figma requires setFillsAsync after loading/referencing the pattern source node."
},
"NOISE": {
"ok": false,
@ -43,5 +69,5 @@
"GRADIENT_LINEAR": 9
}
},
"status": "No real PATTERN, NOISE, or CUSTOM paint payload was available in the connected Figma file or committed .fig fixtures. The plugin runtime rejects creating these paint types directly, so first-class rendering remains blocked on a Figma-authored sample."
"status": "A real PATTERN payload is now captured through Figma's async paint API. Real NOISE and CUSTOM paint payloads are still unavailable in the connected Figma file and committed .fig fixtures, so first-class rendering for those remains blocked on Figma-authored samples."
}