fix(canvas): render Figma pattern fills
This commit is contained in:
parent
63f391d694
commit
d208cc326e
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue