diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 9c50397f1..3496b9b02 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -1,9 +1,10 @@ +import { guidToString } from '@open-pencil/kiwi/fig/guid' + /* eslint-disable max-lines -- kiwi↔scene conversion helpers are tightly coupled */ import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '#core/constants' import { parseVariantName } from '#core/scene-graph/variant-name' import { styleToWeight } from '#core/text/fonts' -import { guidToString } from '@open-pencil/kiwi/fig/guid' import { convertEffects, convertFills, convertStrokes } from './paint' import { importStyleRuns } from './style-runs' export { importStyleRuns } from './style-runs' @@ -133,10 +134,11 @@ function mapNodeType(type?: string): NodeType | 'DOCUMENT' | 'VARIABLE' { function mapBooleanOperation(nc: NodeChange): SceneNode['booleanOperation'] { if (nc.type !== 'BOOLEAN_OPERATION') return undefined - switch (nc.booleanOperation) { + const operation = nc.booleanOperation as NodeChange['booleanOperation'] | 'EXCLUDE' | undefined + switch (operation) { case 'SUBTRACT': case 'INTERSECT': - return nc.booleanOperation + return operation case 'EXCLUDE': case 'XOR': return 'EXCLUDE' diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index 5f480a546..dc1689572 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -1,11 +1,11 @@ import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec' +import { stringToGuid } from '@open-pencil/kiwi/fig/guid' /* eslint-disable max-lines */ import { bytesToHex } from '#core/bytes/hex' import type { SceneGraph, SceneNode } from '#core/scene-graph' import type { Color, GUID, Matrix, Vector } from '#core/types' -import { stringToGuid } from '@open-pencil/kiwi/fig/guid' import { applyExportSettingsPluginData, mergePluginData, @@ -16,6 +16,12 @@ import { export type KiwiNodeChange = NodeChange & Record +type KiwiBooleanOperation = NonNullable + +function toKiwiBooleanOperation(operation: SceneNode['booleanOperation']): KiwiBooleanOperation { + return operation === 'EXCLUDE' ? 'XOR' : (operation ?? 'UNION') +} + /** * Build a mapping from assetRef key strings ("key@version" or "key") to * variable GUIDs. This is used to convert colorVar.assetRef references in raw @@ -704,8 +710,7 @@ export function sceneNodeToKiwiWithContext( if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type) if (nc.type === 'CANVAS') nc.pageType = 'DESIGN' if (node.type === 'BOOLEAN_OPERATION') - nc.booleanOperation = - node.booleanOperation === 'EXCLUDE' ? 'XOR' : (node.booleanOperation ?? 'UNION') + nc.booleanOperation = toKiwiBooleanOperation(node.booleanOperation) if (strokePaints.length > 0) nc.strokePaints = strokePaints context.serializeLayoutProps(node, nc) diff --git a/packages/kiwi/src/fig/codec.ts b/packages/kiwi/src/fig/codec.ts index 7a29aabfd..1a2c18f09 100644 --- a/packages/kiwi/src/fig/codec.ts +++ b/packages/kiwi/src/fig/codec.ts @@ -334,7 +334,7 @@ export interface NodeChange { frameMaskDisabled?: boolean resizeToFit?: boolean // Vector - booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE' | 'XOR' + booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'XOR' vectorData?: unknown fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> diff --git a/tests/engine/io/fig/export/boolean-operation.test.ts b/tests/engine/io/fig/export/boolean-operation.test.ts index b9921b98d..45349af4b 100644 --- a/tests/engine/io/fig/export/boolean-operation.test.ts +++ b/tests/engine/io/fig/export/boolean-operation.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'bun:test' +import { createNodeChangesMessage, encodeMessage, initCodec } from '@open-pencil/kiwi/fig/codec' + import { sceneNodeToKiwi } from '#core/kiwi/fig/node-change/serialize' import { SceneGraph } from '#core/scene-graph' @@ -17,6 +19,21 @@ describe('Figma boolean operation export', () => { expect(changes[0].booleanOperation).toBe('INTERSECT') }) + test('exports exclude as Kiwi XOR', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const node = graph.createNode('BOOLEAN_OPERATION', page.id, { + booleanOperation: 'EXCLUDE' + }) + + const changes = sceneNodeToKiwi(node, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, []) + + expect(changes[0].booleanOperation).toBe('XOR') + + await initCodec() + expect(() => encodeMessage(createNodeChangesMessage(1, 1, changes))).not.toThrow() + }) + test('exports boolean operation children in order', () => { const graph = new SceneGraph() const page = graph.getPages()[0]