feat(canvas): render boolean operation paths
This commit is contained in:
parent
104114cb3f
commit
40d8a54046
106
packages/core/src/canvas/boolean.ts
Normal file
106
packages/core/src/canvas/boolean.ts
Normal file
|
|
@ -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<SceneNode['booleanOperation']>,
|
||||
'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()
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
130
tests/engine/render/canvas/boolean.test.ts
Normal file
130
tests/engine/render/canvas/boolean.test.ts
Normal file
|
|
@ -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<SkiaRenderer> {
|
||||
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<SkiaRenderer>
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue