diff --git a/packages/core/src/canvas/boolean.ts b/packages/core/src/canvas/boolean.ts new file mode 100644 index 000000000..333473386 --- /dev/null +++ b/packages/core/src/canvas/boolean.ts @@ -0,0 +1,106 @@ +import type { Canvas, Path, PathOp } from 'canvaskit-wasm' + +import type { SceneGraph, SceneNode } from '#core/scene-graph' + +import type { SkiaRenderer } from './renderer' +import { nodeHasRadius } from './shapes' + +const BOOLEAN_PATH_OP: Record< + NonNullable, + 'Union' | 'Difference' | 'Intersect' | 'XOR' +> = { + UNION: 'Union', + SUBTRACT: 'Difference', + INTERSECT: 'Intersect', + EXCLUDE: 'XOR' +} + +function childTransform(r: SkiaRenderer, child: SceneNode): number[] { + const transforms = [r.ck.Matrix.translated(child.x, child.y)] + if (child.rotation !== 0) { + transforms.push(r.ck.Matrix.rotated((child.rotation * Math.PI) / 180, child.width / 2, child.height / 2)) + } + if (child.flipX || child.flipY) { + transforms.push( + r.ck.Matrix.scaled(child.flipX ? -1 : 1, child.flipY ? -1 : 1, child.width / 2, child.height / 2) + ) + } + if (transforms.length === 1) return transforms[0] + return r.ck.Matrix.multiply(...transforms) +} + +function shapePath(r: SkiaRenderer, node: SceneNode, graph: SceneGraph): Path | null { + if (node.type === 'BOOLEAN_OPERATION') return makeBooleanOperationPath(r, node, graph) + if (node.type === 'TEXT' || node.type === 'SECTION' || node.type === 'COMPONENT_SET') return null + + const rect = r.ck.LTRBRect(0, 0, node.width, node.height) + return r.makeNodeShapePath(node, rect, nodeHasRadius(node)) +} + +function transformedShapePath( + r: SkiaRenderer, + child: SceneNode, + graph: SceneGraph +): Path | null { + const path = shapePath(r, child, graph) + if (!path) return null + path.transform(childTransform(r, child)) + return path +} + +function operationForNode(r: SkiaRenderer, node: SceneNode): PathOp { + const operation = node.booleanOperation ?? 'UNION' + return r.ck.PathOp[BOOLEAN_PATH_OP[operation]] +} + +export function makeBooleanOperationPath( + r: SkiaRenderer, + node: SceneNode, + graph: SceneGraph +): Path | null { + const childPaths: Path[] = [] + for (const childId of node.childIds) { + const child = graph.getNode(childId) + if (!child || !child.visible) continue + const path = transformedShapePath(r, child, graph) + if (path) childPaths.push(path) + } + + if (childPaths.length === 0) return null + + const first = childPaths[0] + for (const path of childPaths.slice(1)) { + first.op(path, operationForNode(r, node)) + path.delete() + } + return first +} + +export function renderBooleanOperation( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + graph: SceneGraph +): void { + const path = makeBooleanOperationPath(r, node, graph) + if (!path) return + + for (let fillIndex = 0; fillIndex < node.fills.length; fillIndex++) { + const fill = node.fills[fillIndex] + if (!fill.visible || !r.applyFill(fill, node, graph, fillIndex)) continue + r.fillPaint.setAlphaf(fill.opacity) + canvas.drawPath(path, r.fillPaint) + r.fillPaint.setShader(null) + } + + for (const stroke of node.strokes) { + if (!stroke.visible) continue + const color = r.resolveStrokeColor(stroke, 0, node, graph) + r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a)) + r.strokePaint.setStrokeWidth(stroke.weight) + r.strokePaint.setAlphaf(stroke.opacity) + canvas.drawPath(path, r.strokePaint) + } + + path.delete() +} diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 1a22c8deb..75da3d56a 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -5,6 +5,7 @@ import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph' import type { Color } from '#core/types' import { vectorNetworkToCenterlinePath } from '#core/vector' +import { renderBooleanOperation } from './boolean' import { nodeHasRadius } from './shapes' import type { SkiaRenderer, RenderOverlays } from './renderer' @@ -79,6 +80,8 @@ function renderNodeContent( r.renderSection(canvas, node, graph) } else if (node.type === 'COMPONENT_SET') { r.renderComponentSet(canvas, node, graph) + } else if (node.type === 'BOOLEAN_OPERATION') { + renderBooleanOperation(r, canvas, node, graph) } else { r.renderShape(canvas, node, graph) } @@ -103,6 +106,7 @@ function renderChildren( absX: number, absY: number ): void { + if (node.type === 'BOOLEAN_OPERATION') return const isClippableContainer = node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE' if (isClippableContainer && node.clipsContent && node.childIds.length > 0) { diff --git a/tests/engine/render/canvas/boolean.test.ts b/tests/engine/render/canvas/boolean.test.ts new file mode 100644 index 000000000..0fda8d133 --- /dev/null +++ b/tests/engine/render/canvas/boolean.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from 'bun:test' + +import { initCanvasKit } from '#cli/headless' +import { makeBooleanOperationPath } from '#core/canvas/boolean' +import { makeNodeShapePath, makePolygonPath, makeRRect } from '#core/canvas/shapes' +import type { SkiaRenderer } from '#core/canvas/renderer' + +import { createAPI } from '#tests/engine/figma/api/helpers' + +async function createRenderer(): Promise { + const ck = await initCanvasKit() + const renderer = { + ck, + makeNodeShapePath(node, rect, hasRadius) { + return makeNodeShapePath(this, node, rect, hasRadius) + }, + makePolygonPath(node) { + return makePolygonPath(this, node) + }, + makeRRect(node) { + return makeRRect(this, node) + }, + getVectorPaths() { + return null + } + } satisfies Partial + return renderer as SkiaRenderer +} + +describe('boolean operation paths', () => { + test('union combines child shapes into one outline', async () => { + const r = await createRenderer() + const api = createAPI() + const first = api.createRectangle() + const second = api.createRectangle() + first.resize(100, 100) + second.resize(100, 100) + second.x = 50 + + const booleanNode = api.union([first, second], api.currentPage) + const node = api.graph.getNode(booleanNode.id) + expect(node).toBeDefined() + if (!node) return + const path = makeBooleanOperationPath(r, node, api.graph) + + expect(path?.getBounds()).toEqual(new Float32Array([0, 0, 150, 100])) + path?.delete() + }) + + test('subtract keeps the lead child bounds', async () => { + const r = await createRenderer() + const api = createAPI() + const first = api.createRectangle() + const second = api.createRectangle() + first.resize(100, 100) + second.resize(50, 100) + second.x = 50 + + const booleanNode = api.subtract([first, second], api.currentPage) + const node = api.graph.getNode(booleanNode.id) + expect(node).toBeDefined() + if (!node) return + const path = makeBooleanOperationPath(r, node, api.graph) + + expect(path?.getBounds()).toEqual(new Float32Array([0, 0, 50, 100])) + path?.delete() + }) + + test('exclude creates an xor outline', async () => { + const r = await createRenderer() + const api = createAPI() + const first = api.createRectangle() + const second = api.createRectangle() + first.resize(100, 100) + second.resize(100, 100) + second.x = 50 + + const booleanNode = api.exclude([first, second], api.currentPage) + const node = api.graph.getNode(booleanNode.id) + expect(node).toBeDefined() + if (!node) return + const path = makeBooleanOperationPath(r, node, api.graph) + + expect(path?.getBounds()).toEqual(new Float32Array([0, 0, 150, 100])) + path?.delete() + }) + + test('intersect supports transformed children', async () => { + const r = await createRenderer() + const api = createAPI() + const first = api.createRectangle() + const second = api.createRectangle() + first.resize(100, 100) + second.resize(100, 100) + second.x = 50 + second.flipX = true + + const booleanNode = api.intersect([first, second], api.currentPage) + const node = api.graph.getNode(booleanNode.id) + expect(node).toBeDefined() + if (!node) return + const path = makeBooleanOperationPath(r, node, api.graph) + + expect(path?.getBounds()).toEqual(new Float32Array([50, 0, 100, 100])) + path?.delete() + }) + + test('supports nested boolean operation children', async () => { + const r = await createRenderer() + const api = createAPI() + const first = api.createRectangle() + const second = api.createRectangle() + const third = api.createEllipse() + first.resize(100, 100) + second.resize(100, 100) + second.x = 50 + third.resize(50, 50) + third.x = 125 + + const union = api.union([first, second], api.currentPage) + const booleanNode = api.union([union, third], api.currentPage) + const node = api.graph.getNode(booleanNode.id) + expect(node).toBeDefined() + if (!node) return + const path = makeBooleanOperationPath(r, node, api.graph) + + expect(path?.getBounds()).toEqual(new Float32Array([0, 0, 175, 100])) + path?.delete() + }) +})