fix(kiwi): preserve imported boolean geometry

This commit is contained in:
Danila Poyarkov 2026-05-31 13:28:41 +03:00
parent 2dc51375ad
commit 6ee0e5f9e8
7 changed files with 100 additions and 18 deletions

View file

@ -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

View file

@ -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(

View file

@ -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 }>

View file

@ -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 }

View file

@ -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(
{

View file

@ -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)
})
})

View file

@ -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()