diff --git a/CHANGELOG.md b/CHANGELOG.md index 486547283..497828582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/core/src/canvas/fills.ts b/packages/core/src/canvas/fills.ts index d99b42f85..af67c7a48 100644 --- a/packages/core/src/canvas/fills.ts +++ b/packages/core/src/canvas/fills.ts @@ -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 diff --git a/packages/core/src/canvas/masks.ts b/packages/core/src/canvas/masks.ts index 283eafcba..25edf84a0 100644 --- a/packages/core/src/canvas/masks.ts +++ b/packages/core/src/canvas/masks.ts @@ -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 } } diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index a009cbf15..ae100c3bc 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -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) => { diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 010036905..e5361f1e0 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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', diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 27d8ab913..b3f7fbfd3 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -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 diff --git a/tests/engine/io/fig/import/raw-field-coverage.test.ts b/tests/engine/io/fig/import/raw-field-coverage.test.ts new file mode 100644 index 000000000..87d2bbebf --- /dev/null +++ b/tests/engine/io/fig/import/raw-field-coverage.test.ts @@ -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 + +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([]) + }) +}) diff --git a/tests/engine/render/canvas/image-fill.test.ts b/tests/engine/render/canvas/image-fill.test.ts new file mode 100644 index 000000000..672884ce0 --- /dev/null +++ b/tests/engine/render/canvas/image-fill.test.ts @@ -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]]) + }) +}) diff --git a/tests/engine/render/canvas/masks.test.ts b/tests/engine/render/canvas/masks.test.ts index ccf425977..6446813f7 100644 --- a/tests/engine/render/canvas/masks.test.ts +++ b/tests/engine/render/canvas/masks.test.ts @@ -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), {