fix(kiwi): preserve imported boolean geometry
This commit is contained in:
parent
2dc51375ad
commit
6ee0e5f9e8
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
## Unreleased
|
## 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
|
## 0.13.2 — 2026-05-30
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,15 @@ function operationForNode(r: SkiaRenderer, node: SceneNode): PathOp {
|
||||||
return r.ck.PathOp[BOOLEAN_PATH_OP[operation]]
|
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(
|
export function makeBooleanOperationPath(
|
||||||
r: SkiaRenderer,
|
r: SkiaRenderer,
|
||||||
node: SceneNode,
|
node: SceneNode,
|
||||||
|
|
@ -259,14 +268,21 @@ export function makeBooleanOperationPath(
|
||||||
if (path) childPaths.push(path)
|
if (path) childPaths.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (childPaths.length === 0) return null
|
if (childPaths.length === 0) return makeImportedFillGeometryPath(r, node)
|
||||||
|
|
||||||
const first = childPaths[0]
|
const result = childPaths[0]
|
||||||
for (const path of childPaths.slice(1)) {
|
const operation = operationForNode(r, node)
|
||||||
first.op(path, operationForNode(r, node))
|
for (let index = 1; index < childPaths.length; index++) {
|
||||||
|
const path = childPaths[index]
|
||||||
|
const didApply = result.op(path, operation)
|
||||||
path.delete()
|
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(
|
export function renderBooleanOperation(
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ export interface NodeChange {
|
||||||
frameMaskDisabled?: boolean
|
frameMaskDisabled?: boolean
|
||||||
resizeToFit?: boolean
|
resizeToFit?: boolean
|
||||||
// Vector
|
// Vector
|
||||||
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
|
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE' | 'XOR'
|
||||||
vectorData?: unknown
|
vectorData?: unknown
|
||||||
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||||
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,10 @@ function mapBooleanOperation(nc: NodeChange): SceneNode['booleanOperation'] {
|
||||||
switch (nc.booleanOperation) {
|
switch (nc.booleanOperation) {
|
||||||
case 'SUBTRACT':
|
case 'SUBTRACT':
|
||||||
case 'INTERSECT':
|
case 'INTERSECT':
|
||||||
case 'EXCLUDE':
|
|
||||||
return nc.booleanOperation
|
return nc.booleanOperation
|
||||||
|
case 'EXCLUDE':
|
||||||
|
case 'XOR':
|
||||||
|
return 'EXCLUDE'
|
||||||
default:
|
default:
|
||||||
return 'UNION'
|
return 'UNION'
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +231,13 @@ function convertTransformProps(
|
||||||
const sx = flipX ? -1 : 1
|
const sx = flipX ? -1 : 1
|
||||||
rotation = Math.atan2(t.m10 * sx, t.m00 * sx) * (180 / Math.PI)
|
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 = [
|
const corners = [
|
||||||
{ x: 0, y: 0 },
|
{ x: 0, y: 0 },
|
||||||
{ x: width, y: 0 },
|
{ x: width, y: 0 },
|
||||||
|
|
@ -241,6 +250,7 @@ function convertTransformProps(
|
||||||
x = Math.min(...corners.map((point) => point.x))
|
x = Math.min(...corners.map((point) => point.x))
|
||||||
y = Math.min(...corners.map((point) => point.y))
|
y = Math.min(...corners.map((point) => point.y))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { x, y, width, height, rotation, flipX, flipY: false }
|
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'])
|
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', () => {
|
test('defaults missing boolean operations to union', () => {
|
||||||
const props = nodeChangeToProps(
|
const props = nodeChangeToProps(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,20 @@ describe('fig-import: transforms', () => {
|
||||||
expect(imported.y).toBe(7)
|
expect(imported.y).toBe(7)
|
||||||
expect(imported.flipX).toBe(true)
|
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 type { SkiaRenderer } from '#core/canvas/renderer'
|
||||||
import { makeNodeShapePath, makePolygonPath, makeRRect } from '#core/canvas/shapes'
|
import { makeNodeShapePath, makePolygonPath, makeRRect } from '#core/canvas/shapes'
|
||||||
import { BLACK } from '#core/constants'
|
import { BLACK } from '#core/constants'
|
||||||
|
import type { SceneNode } from '#core/scene-graph'
|
||||||
|
|
||||||
import { createAPI } from '#tests/engine/figma/api/helpers'
|
import { createAPI } from '#tests/engine/figma/api/helpers'
|
||||||
|
|
||||||
|
|
@ -164,6 +165,26 @@ describe('boolean operation paths', () => {
|
||||||
path?.delete()
|
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 () => {
|
test('supports nested boolean operation children', async () => {
|
||||||
const r = await createRenderer()
|
const r = await createRenderer()
|
||||||
const api = createAPI()
|
const api = createAPI()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue