diff --git a/packages/core/src/canvas/flatten.ts b/packages/core/src/canvas/flatten.ts new file mode 100644 index 000000000..bdc54783d --- /dev/null +++ b/packages/core/src/canvas/flatten.ts @@ -0,0 +1,44 @@ +import { parseSVGPath } from '#core/io/formats/svg/parse-path' +import type { SceneGraph, SceneNode } from '#core/scene-graph' +import { copyFills } from '#core/scene-graph/copy' + +import { makeBooleanSourcePath, nodePathTransform } from './boolean' +import type { SkiaRenderer } from './renderer' + +export function flattenNodesToVectorProps( + renderer: SkiaRenderer, + graph: SceneGraph, + nodes: SceneNode[] +): Pick | null { + const path = new renderer.ck.Path() + for (const node of nodes) { + const nodePath = makeBooleanSourcePath(renderer, node, graph) + if (!nodePath) { + path.delete() + return null + } + nodePath.transform(nodePathTransform(renderer, node)) + path.addPath(nodePath) + nodePath.delete() + } + + const bounds = path.getBounds() + if (bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) { + path.delete() + return null + } + + path.transform(renderer.ck.Matrix.translated(-bounds[0], -bounds[1])) + const vectorNetwork = parseSVGPath(path.toSVGString()) + path.delete() + + return { + name: 'Flatten', + x: bounds[0], + y: bounds[1], + width: bounds[2] - bounds[0], + height: bounds[3] - bounds[1], + fills: copyFills(nodes[0].fills), + vectorNetwork + } +} diff --git a/packages/core/src/editor/structure/flatten.ts b/packages/core/src/editor/structure/flatten.ts index d6b026f21..795cfe956 100644 --- a/packages/core/src/editor/structure/flatten.ts +++ b/packages/core/src/editor/structure/flatten.ts @@ -1,9 +1,8 @@ -import { canMakeBooleanSourcePath, makeBooleanSourcePath, nodePathTransform } from '#core/canvas/boolean' +import { canMakeBooleanSourcePath } from '#core/canvas/boolean' +import { flattenNodesToVectorProps } from '#core/canvas/flatten' import { restoreSubtree, snapshotSubtree } from '#core/editor/clipboard/subtree-history' import type { EditorContext } from '#core/editor/types' -import { parseSVGPath } from '#core/io/formats/svg/parse-path' import type { SceneNode } from '#core/scene-graph' -import { copyFills } from '#core/scene-graph/copy' import { selectedNodesInSharedParent } from './selection' @@ -20,40 +19,10 @@ export function flattenSelected(ctx: EditorContext, selectedNodes: SceneNode[]) const childSnapshots = childIds.map((id) => ({ id, subtree: snapshotSubtree(ctx.graph, id) })) const prevSelection = new Set(ctx.state.selectedIds) const firstIndex = Math.min(...childIds.map((id) => parent.childIds.indexOf(id))) - const path = new renderer.ck.Path() + const vectorProps = flattenNodesToVectorProps(renderer, ctx.graph, topLevel) + if (!vectorProps) return null - for (const node of topLevel) { - const childPath = makeBooleanSourcePath(renderer, node, ctx.graph) - if (!childPath) { - path.delete() - return null - } - childPath.transform(nodePathTransform(renderer, node)) - path.addPath(childPath) - childPath.delete() - } - - const bounds = path.getBounds() - if (bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) { - path.delete() - return null - } - - path.transform(renderer.ck.Matrix.translated(-bounds[0], -bounds[1])) - const svgPath = path.toSVGString() - path.delete() - - const first = topLevel[0] - const vector = ctx.graph.createNode('VECTOR', parentId, { - name: 'Flatten', - x: bounds[0], - y: bounds[1], - width: bounds[2] - bounds[0], - height: bounds[3] - bounds[1], - fills: copyFills(first.fills), - strokes: [], - vectorNetwork: parseSVGPath(svgPath) - }) + const vector = ctx.graph.createNode('VECTOR', parentId, { ...vectorProps, strokes: [] }) const vectorSnapshot = structuredClone(vector) ctx.graph.insertChildAt(vector.id, parentId, firstIndex) for (const id of childIds) ctx.graph.deleteNode(id) diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index 5235545f5..84cbe2f3b 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -1,8 +1,12 @@ +import type { SkiaRenderer } from '#core/canvas' +import { canMakeBooleanSourcePath } from '#core/canvas/boolean' +import { flattenNodesToVectorProps } from '#core/canvas/flatten' import { IS_BROWSER } from '#core/constants' import { computeBounds } from '#core/geometry' import type { RasterExportFormat } from '#core/io/formats/raster' import type { SceneGraph, + SceneNode as CoreSceneNode, NodeType, Variable, VariableCollection, @@ -96,6 +100,7 @@ export class FigmaAPI implements NodeProxyHost { private _selection: FigmaNodeProxy[] = [] private _nodeCache = new Map() private _pageProxies = new WeakSet() + private _renderer: SkiaRenderer | null = null readonly mixed = MIXED @@ -105,6 +110,10 @@ export class FigmaAPI implements NodeProxyHost { this._currentPageId = pages[0]?.id ?? graph.rootId } + setRenderer(renderer: SkiaRenderer | null): void { + this._renderer = renderer + } + get currentPageId(): string { return this._currentPageId } @@ -424,9 +433,25 @@ export class FigmaAPI implements NodeProxyHost { ): FigmaVectorNode { if (nodes.length === 0) throw new Error('Need at least 1 node to flatten') const parentId = this._nodeId(parent ?? this.currentPage) - const first = this.graph.getNode(this._nodeId(nodes[0])) - if (!first) throw new Error('Node not found') - const vector = this.graph.createNode('VECTOR', parentId, { + const sourceNodes: CoreSceneNode[] = [] + for (const node of nodes) { + const raw = this.graph.getNode(this._nodeId(node)) + if (!raw) throw new Error('Node not found') + sourceNodes.push(raw) + } + const vector = this._renderer + ? this._flattenWithRenderer(sourceNodes, parentId) + : this._flattenPlaceholder(sourceNodes, parentId) + if (index != null) this.graph.reorderChild(vector.id, parentId, index) + for (const node of nodes) { + this.graph.deleteNode(this._nodeId(node)) + } + return this.wrapNode(vector.id) as FigmaVectorNode + } + + private _flattenPlaceholder(nodes: CoreSceneNode[], parentId: string): CoreSceneNode { + const first = nodes[0] + return this.graph.createNode('VECTOR', parentId, { name: 'Flatten', x: first.x, y: first.y, @@ -434,11 +459,18 @@ export class FigmaAPI implements NodeProxyHost { height: first.height, fills: copyFills(first.fills) }) - if (index != null) this.graph.reorderChild(vector.id, parentId, index) - for (const node of nodes) { - this.graph.deleteNode(this._nodeId(node)) + } + + private _flattenWithRenderer(nodes: CoreSceneNode[], parentId: string): CoreSceneNode { + const renderer = this._renderer + if (!renderer) return this._flattenPlaceholder(nodes, parentId) + if (nodes.some((node) => !canMakeBooleanSourcePath(node))) { + throw new Error('Cannot flatten unsupported node type') } - return this.wrapNode(vector.id) as FigmaVectorNode + + const vectorProps = flattenNodesToVectorProps(renderer, this.graph, nodes) + if (!vectorProps) throw new Error('Cannot flatten empty node path') + return this.graph.createNode('VECTOR', parentId, vectorProps) } flattenNode(nodeIds: string[]): FigmaVectorNode { diff --git a/tests/engine/figma/api/flatten-renderer.test.ts b/tests/engine/figma/api/flatten-renderer.test.ts new file mode 100644 index 000000000..16c9b264a --- /dev/null +++ b/tests/engine/figma/api/flatten-renderer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test' + +import { initCanvasKit } from '#cli/headless' +import { SkiaRenderer } from '#core/canvas' + +import { createAPI } from './helpers' + +async function createRenderer() { + const ck = await initCanvasKit() + const surface = ck.MakeSurface(200, 200) + if (!surface) throw new Error('Could not create CanvasKit surface') + return { renderer: new SkiaRenderer(ck, surface), surface } +} + +describe('FigmaAPI renderer-backed flatten', () => { + test('creates vector geometry when a renderer is attached', async () => { + const api = createAPI() + const { renderer, surface } = await createRenderer() + api.setRenderer(renderer) + const first = api.createRectangle() + const second = api.createEllipse() + first.resize(50, 40) + second.resize(50, 40) + second.x = 30 + second.y = 10 + + const vector = api.flatten([first, second], api.currentPage) + const raw = api.graph.getNode(vector.id) + + expect(raw?.type).toBe('VECTOR') + expect(raw?.x).toBe(0) + expect(raw?.y).toBe(0) + expect(raw?.width).toBe(80) + expect(raw?.height).toBe(50) + expect(raw?.vectorNetwork?.vertices.length).toBeGreaterThan(0) + expect(api.getNodeById(first.id)).toBeNull() + surface.delete() + }) +})