fix(vector): preserve open segments with regions (#496)
- Render segments not referenced by any region loop alongside filled paths\n- Add focused path construction coverage and a canvas visual snapshot\n- Resolve Plugin API type-aware lint findings exposed by the full quality gate
This commit is contained in:
parent
71bb3b20a6
commit
3102a60f91
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Preserve open vector segments when the same vector network also contains filled regions. (#450)
|
||||
- Match Figma Plugin API behavior for `rescale()`, page `backgrounds`, and nullable visual `absoluteRenderBounds`. (#442)
|
||||
- Keep imported Figma instances linked to their remapped source components so later component edits update existing instances. (#385)
|
||||
- Restore native copy, cut, and paste shortcuts in desktop text inputs while preserving design clipboard handling on the canvas.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type VisualBounds
|
||||
} from '@open-pencil/scene-graph/geometry'
|
||||
import Matrix from '@open-pencil/scene-graph/matrix'
|
||||
import type { Rect } from '@open-pencil/scene-graph/primitives'
|
||||
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
const INTRINSIC_CONTAINER_TYPES = new Set([
|
||||
'FRAME',
|
||||
|
|
@ -63,11 +63,7 @@ function hasRenderableGeometry(node: SceneNode): boolean {
|
|||
return node.fillGeometry.length > 0 || node.strokeGeometry.length > 0
|
||||
}
|
||||
|
||||
function translatedBounds(
|
||||
bounds: VisualBounds,
|
||||
offset: { x: number; y: number },
|
||||
overflow: number
|
||||
): VisualBounds {
|
||||
function translatedBounds(bounds: VisualBounds, offset: Vector, overflow: number): VisualBounds {
|
||||
return {
|
||||
minX: bounds.minX + offset.x - overflow,
|
||||
minY: bounds.minY + offset.y - overflow,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ const RESCALABLE_NODE_TYPES = new Set<SceneNode['type']>([
|
|||
'LINE',
|
||||
'POLYGON',
|
||||
'RECTANGLE',
|
||||
'SLICE',
|
||||
'STAR',
|
||||
'TEXT',
|
||||
'VECTOR'
|
||||
|
|
|
|||
|
|
@ -30,9 +30,11 @@ export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path
|
|||
|
||||
if (regions.length > 0) {
|
||||
const paths: Path[] = []
|
||||
const regionSegmentIndexes = new Set<number>()
|
||||
for (const region of regions) {
|
||||
const regionPath = new ck.Path()
|
||||
for (const loop of region.loops) {
|
||||
for (const segmentIndex of loop) regionSegmentIndexes.add(segmentIndex)
|
||||
addLoopToPath(regionPath, loop, segments, vertices)
|
||||
}
|
||||
regionPath.setFillType(
|
||||
|
|
@ -40,6 +42,13 @@ export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path
|
|||
)
|
||||
paths.push(regionPath)
|
||||
}
|
||||
|
||||
const openSegments = segments.filter((_, index) => !regionSegmentIndexes.has(index))
|
||||
if (openSegments.length > 0) {
|
||||
const openPath = new ck.Path()
|
||||
addOpenSegmentsToPath(openPath, openSegments, vertices)
|
||||
paths.push(openPath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,61 @@ import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
|
|||
|
||||
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
|
||||
|
||||
test('mixed vector regions keep open stroke segments', async () => {
|
||||
await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
store.graph.createNode('VECTOR', store.state.currentPageId, {
|
||||
name: 'Mixed region and open segment',
|
||||
x: 160,
|
||||
y: 110,
|
||||
width: 300,
|
||||
height: 160,
|
||||
fills: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
|
||||
visible: true,
|
||||
opacity: 1
|
||||
}
|
||||
],
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 0.95, g: 0.25, b: 0.15, a: 1 },
|
||||
weight: 8,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'CENTER'
|
||||
}
|
||||
],
|
||||
vectorNetwork: {
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 120, y: 0 },
|
||||
{ x: 120, y: 120 },
|
||||
{ x: 0, y: 120 },
|
||||
{ x: 170, y: 60 },
|
||||
{ x: 300, y: 60 }
|
||||
],
|
||||
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 } },
|
||||
{ start: 4, end: 5, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
|
||||
],
|
||||
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] }]
|
||||
}
|
||||
})
|
||||
store.clearSelection()
|
||||
store.requestRender()
|
||||
})
|
||||
await editor.canvas.waitForRender()
|
||||
editor.canvas.assertNoErrors()
|
||||
const buffer = await editor.canvas.canvas.screenshot()
|
||||
expect(buffer).toMatchSnapshot('mixed-region-open-segment.png')
|
||||
})
|
||||
|
||||
test('even-odd vector geometry preserves holes', async () => {
|
||||
await editor.page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
98
tests/engine/vector/path-rendering.test.ts
Normal file
98
tests/engine/vector/path-rendering.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import type { CanvasKit } from 'canvaskit-wasm'
|
||||
|
||||
import { vectorNetworkToPath } from '@open-pencil/core/vector'
|
||||
import type { VectorNetwork } from '@open-pencil/scene-graph'
|
||||
|
||||
interface RecordedPath {
|
||||
operations: string[]
|
||||
fillType: number | null
|
||||
}
|
||||
|
||||
function createCanvasKit() {
|
||||
const paths: RecordedPath[] = []
|
||||
class MockPath {
|
||||
operations: string[] = []
|
||||
fillType: number | null = null
|
||||
|
||||
constructor() {
|
||||
paths.push(this)
|
||||
}
|
||||
|
||||
moveTo(x: number, y: number): void {
|
||||
this.operations.push(`M${x},${y}`)
|
||||
}
|
||||
|
||||
lineTo(x: number, y: number): void {
|
||||
this.operations.push(`L${x},${y}`)
|
||||
}
|
||||
|
||||
cubicTo(x1: number, y1: number, x2: number, y2: number, x3: number, y3: number): void {
|
||||
this.operations.push(`C${x1},${y1},${x2},${y2},${x3},${y3}`)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.operations.push('Z')
|
||||
}
|
||||
|
||||
setFillType(value: number): void {
|
||||
this.fillType = value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ck: {
|
||||
Path: MockPath,
|
||||
FillType: { Winding: 0, EvenOdd: 1 }
|
||||
} as CanvasKit,
|
||||
paths
|
||||
}
|
||||
}
|
||||
|
||||
function mixedNetwork(): VectorNetwork {
|
||||
return {
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 0, y: 10 },
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 90, y: 50 }
|
||||
],
|
||||
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 } },
|
||||
{ start: 4, end: 5, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
|
||||
],
|
||||
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2, 3]] }]
|
||||
}
|
||||
}
|
||||
|
||||
describe('vectorNetworkToPath', () => {
|
||||
test('preserves open segments alongside filled regions', () => {
|
||||
const { ck, paths } = createCanvasKit()
|
||||
|
||||
const result = vectorNetworkToPath(ck, mixedNetwork())
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(paths[0]).toEqual({
|
||||
operations: ['M0,0', 'L10,0', 'L10,10', 'L0,10', 'L0,0', 'Z'],
|
||||
fillType: 0
|
||||
})
|
||||
expect(paths[1]).toEqual({ operations: ['M50,50', 'L90,50'], fillType: null })
|
||||
})
|
||||
|
||||
test('does not duplicate segments shared by multiple region loops', () => {
|
||||
const { ck, paths } = createCanvasKit()
|
||||
const network = mixedNetwork()
|
||||
network.regions.push({ windingRule: 'EVENODD', loops: [[0, 1, 2, 3]] })
|
||||
|
||||
const result = vectorNetworkToPath(ck, network)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(paths.filter((path) => path.operations.includes('M50,50'))).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue