* refactor(canvas): adopt immutable CanvasKit paths - Upgrade CanvasKit to 0.41.1 across app, Core, CLI, and Vue peers - Build mutable geometry with PathBuilder and detach immutable paths for drawing - Replace in-place strokes, transforms, and boolean operations with immutable results - Update renderer mocks and path construction tests for the new API * test(canvas): model immutable path ownership - Return detached path instances from CanvasKit builder mocks - Assert shadow ring and combined path disposal - Preserve recorded vector operations across ownership transfer * fix(build): align CanvasKit workspace versions
213 lines
6.9 KiB
TypeScript
213 lines
6.9 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import type { SceneNode } from '@open-pencil/scene-graph'
|
|
|
|
import { initCanvasKit } from '#cli/headless'
|
|
import { makeBooleanOperationPath } from '#core/canvas/boolean'
|
|
import type { SkiaRenderer } from '#core/canvas/renderer'
|
|
import { makeNodeShapePath, makePolygonPath, makeRRect } from '#core/canvas/shapes'
|
|
import { BLACK } from '#core/constants'
|
|
|
|
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 ellipse arc wedges', async () => {
|
|
const r = await createRenderer()
|
|
const api = createAPI()
|
|
const first = api.createEllipse()
|
|
const second = api.createEllipse()
|
|
first.resize(100, 100)
|
|
second.resize(100, 100)
|
|
second.x = 50
|
|
const firstNode = api.graph.getNode(first.id)
|
|
const secondNode = api.graph.getNode(second.id)
|
|
expect(firstNode).toBeDefined()
|
|
expect(secondNode).toBeDefined()
|
|
if (!firstNode || !secondNode) return
|
|
firstNode.arcData = { startingAngle: 0, endingAngle: Math.PI, innerRadius: 0 }
|
|
secondNode.arcData = { startingAngle: 0, endingAngle: Math.PI, innerRadius: 0 }
|
|
|
|
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, 50, 150, 100]))
|
|
path?.delete()
|
|
})
|
|
|
|
test('turns lines into stroke outlines', async () => {
|
|
const r = await createRenderer()
|
|
const api = createAPI()
|
|
const first = api.createLine()
|
|
const second = api.createLine()
|
|
first.resize(100, 100)
|
|
second.resize(100, 100)
|
|
second.x = 50
|
|
const firstNode = api.graph.getNode(first.id)
|
|
const secondNode = api.graph.getNode(second.id)
|
|
expect(firstNode).toBeDefined()
|
|
expect(secondNode).toBeDefined()
|
|
if (!firstNode || !secondNode) return
|
|
firstNode.strokes = [
|
|
{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 12, align: 'CENTER' }
|
|
]
|
|
secondNode.strokes = [
|
|
{ type: 'SOLID', color: BLACK, opacity: 1, visible: true, weight: 12, align: 'CENTER' }
|
|
]
|
|
|
|
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)
|
|
const bounds = path?.getBounds()
|
|
|
|
expect(bounds?.[2]).toBeGreaterThan(145)
|
|
expect(bounds?.[3]).toBeGreaterThan(100)
|
|
path?.delete()
|
|
})
|
|
|
|
test('uses imported fill geometry when child paths cannot produce a boolean path', async () => {
|
|
const r = await createRenderer()
|
|
const importedPathBuilder = new r.ck.PathBuilder()
|
|
importedPathBuilder.addRect(r.ck.LTRBRect(5, 6, 25, 36))
|
|
const importedPath = importedPathBuilder.detachAndDelete()
|
|
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 () => {
|
|
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()
|
|
})
|
|
})
|