fix(kiwi): preserve imported boolean geometry
This commit is contained in:
parent
2dc51375ad
commit
6ee0e5f9e8
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }>
|
||||
|
|
|
|||
|
|
@ -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,6 +231,13 @@ function convertTransformProps(
|
|||
const sx = flipX ? -1 : 1
|
||||
rotation = Math.atan2(t.m10 * sx, t.m00 * sx) * (180 / Math.PI)
|
||||
|
||||
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 },
|
||||
|
|
@ -241,6 +250,7 @@ function convertTransformProps(
|
|||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue