fix(fig): preserve vector winding rules

- Align imported and derived geometry with vector-region winding

- Cover even-odd holes with unit and reviewed canvas snapshots
This commit is contained in:
Danila Poyarkov 2026-07-22 16:36:30 +03:00
parent 8dfed26d16
commit f31e51f987
6 changed files with 145 additions and 4 deletions

View file

@ -1,4 +1,4 @@
import { resolveGeometryPaths } from '@open-pencil/fig/node-change'
import { alignGeometryWindingRules, resolveGeometryPaths } from '@open-pencil/fig/node-change'
import type { GeometryPath, SceneNode } from '@open-pencil/scene-graph'
import type { DerivedSymbolOverride } from '../types'
@ -35,7 +35,7 @@ export function resolveDsdGeometry(
const fg = resolveGeometryPaths(d.fillGeometry, blobs)
const sg = resolveGeometryPaths(d.strokeGeometry, blobs)
if (fg.length > 0) result.fillGeometry = fg
if (fg.length > 0) result.fillGeometry = alignGeometryWindingRules(fg, target.vectorNetwork)
else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.fillGeometry = scaleGeometryBlobs(
target.fillGeometry,

View file

@ -23,7 +23,11 @@ import {
} from './plugin-data'
import { importStyleRuns } from './style-runs'
import { convertLetterSpacing, convertLineHeight, mapTextDecoration } from './text-values'
import { resolveGeometryPaths, resolveVectorNetwork } from './vector-geometry'
import {
alignGeometryWindingRules,
resolveGeometryPaths,
resolveVectorNetwork
} from './vector-geometry'
export { convertEffects, convertFills, convertStrokes, setVariableColorResolver } from './paint'
export { importStyleRuns } from './style-runs'
@ -557,9 +561,13 @@ function convertVectorAndStrokeProps(nc: NodeChange, blobs: Uint8Array[]) {
const vectorNetwork = resolveVectorNetwork(nc, blobs)
const strokeCap = getVectorStrokeCap(nc, vectorNetwork)
const strokeJoin = getVectorStrokeJoin(nc, vectorNetwork)
const fillGeometry = alignGeometryWindingRules(
resolveGeometryPaths(nc.fillGeometry, blobs),
vectorNetwork
)
return {
vectorNetwork,
fillGeometry: resolveGeometryPaths(nc.fillGeometry, blobs),
fillGeometry,
strokeGeometry: resolveGeometryPaths(nc.strokeGeometry, blobs),
arcData: mapArcData(nc.arcData as Partial<ArcData> | undefined),
strokeCap,

View file

@ -4,6 +4,27 @@ import type { Vector } from '@open-pencil/scene-graph/primitives'
import { decodeVectorNetworkBlob } from './vector-network'
export function alignGeometryWindingRules(
geometry: GeometryPath[],
vectorNetwork: VectorNetwork | null
): GeometryPath[] {
const regions = vectorNetwork?.regions ?? []
if (geometry.length === regions.length) {
return geometry.map((path, index) => ({
...path,
windingRule: regions[index].windingRule
}))
}
if (
geometry.length === 1 &&
regions.length > 0 &&
regions.every((region) => region.windingRule === regions[0].windingRule)
) {
return [{ ...geometry[0], windingRule: regions[0].windingRule }]
}
return geometry
}
export function resolveVectorNetwork(nc: NodeChange, blobs: Uint8Array[]): VectorNetwork | null {
const vectorData = nc.vectorData as
| {

View file

@ -1,5 +1,7 @@
import { describe, expect, test } from 'bun:test'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import {
applyStyleRefsToFields,
buildStyleOverrideTable,
@ -10,8 +12,10 @@ import {
convertLineHeight,
convertStrokes,
decodeVectorNetworkBlob,
encodePathCommandsBlob,
encodeVectorNetworkBlob,
mapTextDecoration,
nodeChangeToProps,
setVariableColorResolver
} from '../src/node-change'
@ -68,6 +72,55 @@ describe('@open-pencil/fig NodeChange policy', () => {
}
})
test('uses vector-region winding rules for rendered geometry', () => {
const network = {
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' as const },
{ x: 10, y: 0, handleMirroring: 'NONE' as const },
{ x: 0, y: 10, handleMirroring: 'NONE' as const }
],
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: 0,
tangentStart: { x: 0, y: 0 },
tangentEnd: { x: 0, y: 0 }
}
],
regions: [{ windingRule: 'EVENODD' as const, loops: [[0, 1, 2]] }]
}
const props = nodeChangeToProps(
{
type: 'VECTOR',
fillGeometry: [{ windingRule: 'NONZERO', commandsBlob: 0 }],
vectorData: { vectorNetworkBlob: 1 }
} as NodeChange,
[
encodePathCommandsBlob([
{ type: 'M', x: 0, y: 0 },
{ type: 'L', x: 10, y: 0 },
{ type: 'L', x: 0, y: 10 },
{ type: 'Z' }
]),
encodeVectorNetworkBlob(network)
]
)
expect(props.fillGeometry[0]?.windingRule).toBe('EVENODD')
})
test('round-trips vector network blobs with handle mirroring', () => {
const network = {
vertices: [

View file

@ -0,0 +1,59 @@
import { expect, test, useEditorSetupWithClear } from '#tests/e2e/fixtures'
const editor = useEditorSetupWithClear('/?test&no-chrome&no-rulers')
test('even-odd vector geometry preserves holes', async () => {
await editor.page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const bytes: number[] = []
const pushFloat = (value: number) => {
const buffer = new ArrayBuffer(4)
new DataView(buffer).setFloat32(0, value, true)
bytes.push(...new Uint8Array(buffer))
}
const moveTo = (x: number, y: number) => {
bytes.push(1)
pushFloat(x)
pushFloat(y)
}
const lineTo = (x: number, y: number) => {
bytes.push(2)
pushFloat(x)
pushFloat(y)
}
const rectangle = (x: number, y: number, width: number, height: number) => {
moveTo(x, y)
lineTo(x + width, y)
lineTo(x + width, y + height)
lineTo(x, y + height)
bytes.push(0)
}
rectangle(0, 0, 240, 144)
rectangle(28, 28, 184, 88)
rectangle(48, 48, 144, 12)
store.graph.createNode('VECTOR', store.state.currentPageId, {
name: 'Even-odd card outline',
x: 160,
y: 110,
width: 240,
height: 144,
fills: [
{
type: 'SOLID',
color: { r: 0.23, g: 0.51, b: 0.96, a: 1 },
visible: true,
opacity: 1
}
],
fillGeometry: [{ windingRule: 'EVENODD', commandsBlob: new Uint8Array(bytes) }]
})
store.clearSelection()
store.requestRender()
})
await editor.canvas.waitForRender()
editor.canvas.assertNoErrors()
const buffer = await editor.canvas.canvas.screenshot()
expect(buffer).toMatchSnapshot('even-odd-vector-holes.png')
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB