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:
parent
8dfed26d16
commit
f31e51f987
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
| {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
59
tests/e2e/canvas/vector-winding-visual.spec.ts
Normal file
59
tests/e2e/canvas/vector-winding-visual.spec.ts
Normal 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 |
Loading…
Reference in a new issue