fix(canvas): support luminance masks and tile transforms
This commit is contained in:
parent
f8db73949e
commit
3dc0904466
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
92
tests/engine/io/fig/import/raw-field-coverage.test.ts
Normal file
92
tests/engine/io/fig/import/raw-field-coverage.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
49
tests/engine/render/canvas/image-fill.test.ts
Normal file
49
tests/engine/render/canvas/image-fill.test.ts
Normal 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]])
|
||||
})
|
||||
})
|
||||
|
|
@ -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), {
|
||||
|
|
|
|||
Loading…
Reference in a new issue