* 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
115 lines
3.3 KiB
TypeScript
115 lines
3.3 KiB
TypeScript
import { describe, expect, mock, test } from 'bun:test'
|
|
|
|
import type { Canvas } from 'canvaskit-wasm'
|
|
|
|
import type { SceneNode } from '@open-pencil/scene-graph'
|
|
|
|
import { renderEffects } from '#core/canvas/shadows'
|
|
|
|
import { createMockCanvas, createMockRenderer, mockCalls } from './helpers'
|
|
|
|
const requiredNodeFields = { childIds: [] as string[], strokeGeometry: [] }
|
|
|
|
describe('path shape shadows', () => {
|
|
test('drop shadow for a star without imported geometry uses the star path', () => {
|
|
const r = createMockRenderer()
|
|
const canvas = createMockCanvas()
|
|
const node: Partial<SceneNode> = {
|
|
...requiredNodeFields,
|
|
type: 'STAR',
|
|
width: 100,
|
|
height: 100,
|
|
fills: [],
|
|
effects: [
|
|
{
|
|
type: 'DROP_SHADOW',
|
|
visible: true,
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
offset: { x: 0, y: 0 },
|
|
radius: 10,
|
|
spread: 5
|
|
}
|
|
]
|
|
}
|
|
|
|
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
|
|
|
|
expect(r.makePolygonPath).toHaveBeenCalled()
|
|
expect(canvas.drawPath).toHaveBeenCalled()
|
|
expect(canvas.drawRect).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('drop shadow spread combines and deletes a detached stroked ring', () => {
|
|
const r = createMockRenderer()
|
|
const canvas = createMockCanvas()
|
|
const cachedPath = new r.ck.Path()
|
|
const ringPath = new r.ck.Path()
|
|
const combinedPath = new r.ck.Path()
|
|
cachedPath.makeStroked = mock(() => ringPath)
|
|
r.ck.Path.MakeFromOp = mock(() => combinedPath)
|
|
r.getFillGeometry = mock(() => [cachedPath])
|
|
|
|
const node: Partial<SceneNode> = {
|
|
...requiredNodeFields,
|
|
type: 'STAR',
|
|
width: 100,
|
|
height: 100,
|
|
fills: [{ visible: true, type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1 }],
|
|
effects: [
|
|
{
|
|
type: 'DROP_SHADOW',
|
|
visible: true,
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
offset: { x: 0, y: 0 },
|
|
radius: 10,
|
|
spread: 10
|
|
}
|
|
]
|
|
}
|
|
|
|
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
|
|
|
|
const makeFromOp = r.ck.Path.MakeFromOp as ReturnType<typeof mock>
|
|
expect(makeFromOp).toHaveBeenCalledWith(cachedPath, ringPath, r.ck.PathOp.Union)
|
|
expect(ringPath.delete).toHaveBeenCalledTimes(1)
|
|
expect(combinedPath.delete).toHaveBeenCalledTimes(1)
|
|
expect(cachedPath.delete).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('inner shadow for a polygon clips and cuts out with polygon paths', () => {
|
|
const r = createMockRenderer()
|
|
const canvas = createMockCanvas()
|
|
const node: Partial<SceneNode> = {
|
|
...requiredNodeFields,
|
|
type: 'POLYGON',
|
|
width: 100,
|
|
height: 100,
|
|
fills: [],
|
|
effects: [
|
|
{
|
|
type: 'INNER_SHADOW',
|
|
visible: true,
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
offset: { x: 5, y: 5 },
|
|
radius: 10,
|
|
spread: 3
|
|
}
|
|
]
|
|
}
|
|
|
|
renderEffects(
|
|
r,
|
|
canvas as Canvas,
|
|
node as SceneNode,
|
|
new Float32Array([0, 0, 100, 100]),
|
|
false,
|
|
'front'
|
|
)
|
|
|
|
expect(mockCalls(r.makePolygonPath).length).toBeGreaterThanOrEqual(2)
|
|
expect(canvas.clipPath).toHaveBeenCalled()
|
|
expect(canvas.drawPath).toHaveBeenCalled()
|
|
expect(canvas.clipRect).not.toHaveBeenCalled()
|
|
})
|
|
})
|