From 6ee0e5f9e8e59d691849e471d34fe1cce4f40b24 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 31 May 2026 13:28:41 +0300 Subject: [PATCH] fix(kiwi): preserve imported boolean geometry --- CHANGELOG.md | 5 +++ packages/core/src/canvas/boolean.ts | 26 +++++++++++--- packages/core/src/kiwi/fig/codec/index.ts | 2 +- .../core/src/kiwi/fig/node-change/convert.ts | 34 ++++++++++++------- .../io/fig/import/boolean-operation.test.ts | 14 ++++++++ .../io/fig/import/legacy/transforms.test.ts | 16 +++++++++ tests/engine/render/canvas/boolean.test.ts | 21 ++++++++++++ 7 files changed, 100 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cfe4ff10..605e80fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Fixes + +- Improve Figma boolean imports by preserving XOR operations as editable exclude nodes and falling back to imported fill geometry when boolean path reconstruction cannot produce a path. +- Preserve rotated Figma transform origins for imported vector nodes. + ## 0.13.2 — 2026-05-30 ### Changed diff --git a/packages/core/src/canvas/boolean.ts b/packages/core/src/canvas/boolean.ts index 769ea79e1..a8bcd1fe7 100644 --- a/packages/core/src/canvas/boolean.ts +++ b/packages/core/src/canvas/boolean.ts @@ -246,6 +246,15 @@ function operationForNode(r: SkiaRenderer, node: SceneNode): PathOp { return r.ck.PathOp[BOOLEAN_PATH_OP[operation]] } +function makeImportedFillGeometryPath(r: SkiaRenderer, node: SceneNode): Path | null { + if (typeof r.getFillGeometry !== 'function') return null + const fillGeometry = r.getFillGeometry(node) + if (!fillGeometry) return null + const result = new r.ck.Path() + for (const path of fillGeometry) result.addPath(path) + return result +} + export function makeBooleanOperationPath( r: SkiaRenderer, node: SceneNode, @@ -259,14 +268,21 @@ export function makeBooleanOperationPath( if (path) childPaths.push(path) } - if (childPaths.length === 0) return null + if (childPaths.length === 0) return makeImportedFillGeometryPath(r, node) - const first = childPaths[0] - for (const path of childPaths.slice(1)) { - first.op(path, operationForNode(r, node)) + const result = childPaths[0] + const operation = operationForNode(r, node) + for (let index = 1; index < childPaths.length; index++) { + const path = childPaths[index] + const didApply = result.op(path, operation) path.delete() + if (!didApply) { + result.delete() + for (const remaining of childPaths.slice(index + 1)) remaining.delete() + return makeImportedFillGeometryPath(r, node) + } } - return first + return result } export function renderBooleanOperation( diff --git a/packages/core/src/kiwi/fig/codec/index.ts b/packages/core/src/kiwi/fig/codec/index.ts index 9d9634ce0..3a4ee5229 100644 --- a/packages/core/src/kiwi/fig/codec/index.ts +++ b/packages/core/src/kiwi/fig/codec/index.ts @@ -329,7 +329,7 @@ export interface NodeChange { frameMaskDisabled?: boolean resizeToFit?: boolean // Vector - booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE' + booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE' | 'XOR' vectorData?: unknown fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 37e58974b..6f5e0190a 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -112,8 +112,10 @@ function mapBooleanOperation(nc: NodeChange): SceneNode['booleanOperation'] { switch (nc.booleanOperation) { case 'SUBTRACT': case 'INTERSECT': - case 'EXCLUDE': return nc.booleanOperation + case 'EXCLUDE': + case 'XOR': + return 'EXCLUDE' default: return 'UNION' } @@ -229,17 +231,25 @@ function convertTransformProps( const sx = flipX ? -1 : 1 rotation = Math.atan2(t.m10 * sx, t.m00 * sx) * (180 / Math.PI) - const corners = [ - { x: 0, y: 0 }, - { x: width, y: 0 }, - { x: 0, y: height }, - { x: width, y: height } - ].map((point) => ({ - x: t.m00 * point.x + t.m01 * point.y + t.m02, - y: t.m10 * point.x + t.m11 * point.y + t.m12 - })) - x = Math.min(...corners.map((point) => point.x)) - y = Math.min(...corners.map((point) => point.y)) + if (rotation !== 0 && !flipX) { + const radians = (rotation * Math.PI) / 180 + const cos = Math.cos(radians) + const sin = Math.sin(radians) + x = t.m02 - (width / 2) * (1 - cos) - sin * (height / 2) + y = t.m12 - (height / 2) * (1 - cos) + sin * (width / 2) + } else { + const corners = [ + { x: 0, y: 0 }, + { x: width, y: 0 }, + { x: 0, y: height }, + { x: width, y: height } + ].map((point) => ({ + x: t.m00 * point.x + t.m01 * point.y + t.m02, + y: t.m10 * point.x + t.m11 * point.y + t.m12 + })) + x = Math.min(...corners.map((point) => point.x)) + y = Math.min(...corners.map((point) => point.y)) + } } return { x, y, width, height, rotation, flipX, flipY: false } diff --git a/tests/engine/io/fig/import/boolean-operation.test.ts b/tests/engine/io/fig/import/boolean-operation.test.ts index 47323b8e1..0396a1e98 100644 --- a/tests/engine/io/fig/import/boolean-operation.test.ts +++ b/tests/engine/io/fig/import/boolean-operation.test.ts @@ -69,6 +69,20 @@ describe('Figma boolean operation import', () => { expect(children.map((child) => child.type)).toEqual(['RECTANGLE', 'ELLIPSE']) }) + test('maps Kiwi XOR boolean operations to scene graph exclude', () => { + const props = nodeChangeToProps( + { + type: 'BOOLEAN_OPERATION', + name: 'Imported boolean', + booleanOperation: 'XOR' + } as NodeChange, + [] + ) + + expect(props.nodeType).toBe('BOOLEAN_OPERATION') + expect(props.booleanOperation).toBe('EXCLUDE') + }) + test('defaults missing boolean operations to union', () => { const props = nodeChangeToProps( { diff --git a/tests/engine/io/fig/import/legacy/transforms.test.ts b/tests/engine/io/fig/import/legacy/transforms.test.ts index 95cef49b1..f1bfea76c 100644 --- a/tests/engine/io/fig/import/legacy/transforms.test.ts +++ b/tests/engine/io/fig/import/legacy/transforms.test.ts @@ -20,4 +20,20 @@ describe('fig-import: transforms', () => { expect(imported.y).toBe(7) expect(imported.flipX).toBe(true) }) + + test('rotated transforms preserve the Figma matrix origin', () => { + const graph = importNodeChanges([ + doc(), + canvas(), + node('VECTOR', 10, 1, { + size: { x: 100, y: 20 }, + transform: { m00: 0, m01: -1, m02: 10, m10: 1, m11: 0, m12: 20 } + }) + ]) + const imported = graph.getChildren(graph.getPages()[0].id)[0] + + expect(imported.x).toBeCloseTo(-50) + expect(imported.y).toBeCloseTo(60) + expect(imported.rotation).toBeCloseTo(90) + }) }) diff --git a/tests/engine/render/canvas/boolean.test.ts b/tests/engine/render/canvas/boolean.test.ts index 62f1b02cf..907bf6572 100644 --- a/tests/engine/render/canvas/boolean.test.ts +++ b/tests/engine/render/canvas/boolean.test.ts @@ -5,6 +5,7 @@ import { makeBooleanOperationPath } from '#core/canvas/boolean' import type { SkiaRenderer } from '#core/canvas/renderer' import { makeNodeShapePath, makePolygonPath, makeRRect } from '#core/canvas/shapes' import { BLACK } from '#core/constants' +import type { SceneNode } from '#core/scene-graph' import { createAPI } from '#tests/engine/figma/api/helpers' @@ -164,6 +165,26 @@ describe('boolean operation paths', () => { path?.delete() }) + test('uses imported fill geometry when child paths cannot produce a boolean path', async () => { + const r = await createRenderer() + const importedPath = new r.ck.Path() + importedPath.addRect(r.ck.LTRBRect(5, 6, 25, 36)) + r.getFillGeometry = () => [importedPath] + const node = { + id: 'boolean', + type: 'BOOLEAN_OPERATION', + childIds: [], + booleanOperation: 'UNION' + } as SceneNode + const api = createAPI() + + const path = makeBooleanOperationPath(r, node, api.graph) + + expect(path?.getBounds()).toEqual(new Float32Array([5, 6, 25, 36])) + path?.delete() + importedPath.delete() + }) + test('supports nested boolean operation children', async () => { const r = await createRenderer() const api = createAPI()