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:
Danila Poyarkov 2026-08-12 19:28:37 +03:00 committed by GitHub
parent 71bb3b20a6
commit 3102a60f91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 165 additions and 7 deletions

View file

@ -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.

View file

@ -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,

View file

@ -23,7 +23,6 @@ const RESCALABLE_NODE_TYPES = new Set<SceneNode['type']>([
'LINE',
'POLYGON',
'RECTANGLE',
'SLICE',
'STAR',
'TEXT',
'VECTOR'

View file

@ -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
}

View file

@ -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

View 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)
})
})