diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3b1ec2a..c04eb52ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/figma-api/render-bounds.ts b/packages/core/src/figma-api/render-bounds.ts index 07c5c8f0e..03be6040c 100644 --- a/packages/core/src/figma-api/render-bounds.ts +++ b/packages/core/src/figma-api/render-bounds.ts @@ -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, diff --git a/packages/core/src/figma-api/rescale.ts b/packages/core/src/figma-api/rescale.ts index b570c1573..24fbf16d5 100644 --- a/packages/core/src/figma-api/rescale.ts +++ b/packages/core/src/figma-api/rescale.ts @@ -23,7 +23,6 @@ const RESCALABLE_NODE_TYPES = new Set([ 'LINE', 'POLYGON', 'RECTANGLE', - 'SLICE', 'STAR', 'TEXT', 'VECTOR' diff --git a/packages/core/src/vector/index.ts b/packages/core/src/vector/index.ts index 366440fe2..ea7115cde 100644 --- a/packages/core/src/vector/index.ts +++ b/packages/core/src/vector/index.ts @@ -30,9 +30,11 @@ export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path if (regions.length > 0) { const paths: Path[] = [] + const regionSegmentIndexes = new Set() 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 } diff --git a/tests/e2e/canvas/vector/winding.spec.ts b/tests/e2e/canvas/vector/winding.spec.ts index b815ed1f8..ebd32e879 100644 --- a/tests/e2e/canvas/vector/winding.spec.ts +++ b/tests/e2e/canvas/vector/winding.spec.ts @@ -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?.() diff --git a/tests/e2e/canvas/vector/winding.spec.ts-snapshots/mixed-region-open-segment-openpencil-darwin.png b/tests/e2e/canvas/vector/winding.spec.ts-snapshots/mixed-region-open-segment-openpencil-darwin.png new file mode 100644 index 000000000..1aa31f5aa Binary files /dev/null and b/tests/e2e/canvas/vector/winding.spec.ts-snapshots/mixed-region-open-segment-openpencil-darwin.png differ diff --git a/tests/engine/vector/path-rendering.test.ts b/tests/engine/vector/path-rendering.test.ts new file mode 100644 index 000000000..b8127ee22 --- /dev/null +++ b/tests/engine/vector/path-rendering.test.ts @@ -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) + }) +})