- Resolve the active graph at action time and map edit geometry through full world transforms - Regenerate live path fills and discard stale imported stroke outlines after edits - Preserve fill and stroke geometry through resize previews, undo, and redo - Cover graph replacement, nested transforms, edit history, resizing, and CanvasKit output Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
116 lines
3.6 KiB
TypeScript
116 lines
3.6 KiB
TypeScript
import { describe, test, expect } from 'bun:test'
|
|
|
|
import { regenerateFillGeometry } from '@open-pencil/core/vector'
|
|
import type { GeometryPath, VectorNetwork } from '@open-pencil/scene-graph'
|
|
|
|
function square(): VectorNetwork {
|
|
return {
|
|
vertices: [
|
|
{ x: 0, y: 0 },
|
|
{ x: 10, y: 0 },
|
|
{ x: 10, y: 10 },
|
|
{ x: 0, y: 10 }
|
|
],
|
|
segments: [
|
|
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
|
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
|
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
|
|
{ start: 3, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
|
|
],
|
|
regions: [{ windingRule: 'EVENODD', loops: [[0, 1, 2, 3]] }]
|
|
}
|
|
}
|
|
|
|
/** Decode a commandsBlob into a readable command list for assertions. */
|
|
function decode(blob: Uint8Array): string[] {
|
|
const view = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
|
|
const out: string[] = []
|
|
let o = 0
|
|
while (o < blob.length) {
|
|
const cmd = blob[o]
|
|
o += 1
|
|
if (cmd === 0) {
|
|
out.push('Z')
|
|
} else if (cmd === 1 || cmd === 2) {
|
|
out.push(
|
|
`${cmd === 1 ? 'M' : 'L'} ${view.getFloat32(o, true)},${view.getFloat32(o + 4, true)}`
|
|
)
|
|
o += 8
|
|
} else if (cmd === 4) {
|
|
out.push(`C ${view.getFloat32(o + 16, true)},${view.getFloat32(o + 20, true)}`)
|
|
o += 24
|
|
} else {
|
|
throw new Error(`unknown cmd ${cmd}`)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
const ENTRY: GeometryPath = {
|
|
windingRule: 'NONZERO',
|
|
commandsBlob: new Uint8Array([9, 9, 9]),
|
|
fills: [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
|
opacity: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL'
|
|
}
|
|
]
|
|
}
|
|
|
|
describe('regenerateFillGeometry', () => {
|
|
test('rebuilds region blobs positionally, preserving fills', () => {
|
|
const network = square()
|
|
const [g] = regenerateFillGeometry(network, [ENTRY])
|
|
expect(g.fills).toEqual(ENTRY.fills)
|
|
expect(g.windingRule).toBe('EVENODD') // taken from the region
|
|
expect(decode(g.commandsBlob)).toEqual(['M 0,0', 'L 10,0', 'L 10,10', 'L 0,10', 'L 0,0', 'Z'])
|
|
})
|
|
|
|
test('edited vertices land in the regenerated blob', () => {
|
|
const network = square()
|
|
network.vertices[2] = { x: 14, y: 12 }
|
|
const [g] = regenerateFillGeometry(network, [ENTRY])
|
|
expect(decode(g.commandsBlob)).toContain('L 14,12')
|
|
})
|
|
|
|
test('curved segments emit cubics', () => {
|
|
const network = square()
|
|
network.segments[0].tangentStart = { x: 3, y: 4 }
|
|
const [g] = regenerateFillGeometry(network, [ENTRY])
|
|
expect(decode(g.commandsBlob)[1]).toBe('C 10,0')
|
|
})
|
|
|
|
test('region/entry count mismatch rebuilds without stale styles', () => {
|
|
const network = square()
|
|
// Two style entries but only one region — mapping is unknown, so rebuild
|
|
// one path per region and drop the mismatched style metadata.
|
|
const out = regenerateFillGeometry(network, [ENTRY, { ...ENTRY }])
|
|
expect(out).toHaveLength(1)
|
|
const path = out[0]
|
|
expect(path).toBeDefined()
|
|
if (!path) return
|
|
expect(path.fills).toBeUndefined()
|
|
expect(path.commandsBlob).not.toBe(ENTRY.commandsBlob)
|
|
expect(decode(path.commandsBlob)).toEqual([
|
|
'M 0,0',
|
|
'L 10,0',
|
|
'L 10,10',
|
|
'L 0,10',
|
|
'L 0,0',
|
|
'Z'
|
|
])
|
|
})
|
|
|
|
test('regionless networks rebuild a single entry from segment chains', () => {
|
|
const network = square()
|
|
network.regions = []
|
|
const [g] = regenerateFillGeometry(network, [ENTRY])
|
|
const cmds = decode(g.commandsBlob)
|
|
expect(cmds[0]).toBe('M 0,0')
|
|
expect(cmds).toHaveLength(5) // M + 4 line segments around the square
|
|
})
|
|
})
|