refactor(fig): own vector blob conversion

- Move vector network blob codecs and NodeChange geometry resolution into @open-pencil/fig\n- Preserve the existing core vector compatibility exports without pass-through wrappers\n- Add package-local round-trip coverage for handle mirroring
This commit is contained in:
Danila Poyarkov 2026-07-18 01:41:31 +03:00
parent db056317a8
commit c2f3338877
6 changed files with 213 additions and 203 deletions

View file

@ -23,6 +23,8 @@ import {
getOpenPencilPluginValue,
LAYOUT_DIRECTION_PLUGIN_KEY,
NODE_TYPE_PLUGIN_KEY,
resolveGeometryPaths,
resolveVectorNetwork,
TEXT_DIRECTION_PLUGIN_KEY,
mapTextDecoration
} from '@open-pencil/fig/node-change'
@ -36,8 +38,7 @@ export {
mapTextDecoration,
setVariableColorResolver
} from '@open-pencil/fig/node-change'
import { resolveGeometryPaths, resolveVectorNetwork } from './vector-geometry'
export { resolveGeometryPaths } from './vector-geometry'
export { resolveGeometryPaths } from '@open-pencil/fig/node-change'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import type {

View file

@ -12,210 +12,16 @@ export {
import type { CanvasKit, Path } from 'canvaskit-wasm'
import type {
HandleMirroring,
VectorNetwork,
VectorRegion,
VectorSegment,
VectorVertex,
WindingRule
} from '@open-pencil/scene-graph'
export {
buildStyleOverrideTable,
decodeVectorNetworkBlob,
encodeVectorNetworkBlob
} from '@open-pencil/fig/node-change'
import type { VectorNetwork, VectorSegment, VectorVertex } from '@open-pencil/scene-graph'
import { addOpenSegmentsToPath, addSegmentDirected } from './path-helpers'
export { vectorNetworkToCenterlinePath, fitCircleArc, isClosedThinCrescent } from './centerline'
// --- vectorNetworkBlob binary format ---
// Header: [numVertices:u32, numSegments:u32, numRegions:u32] (12 bytes)
// Vertex: [styleOverrideIdx:u32, x:f32, y:f32] (12 bytes)
// Segment: [styleOverrideIdx:u32, start:u32, tsX:f32, tsY:f32, end:u32, teX:f32, teY:f32] (28 bytes)
// Region: [windingRule:u32, numLoops:u32, {numSegs:u32, segIdx...}... ] (variable)
interface StyleOverride {
styleID: number
handleMirroring?: string
}
export function decodeVectorNetworkBlob(
data: Uint8Array,
styleOverrideTable?: StyleOverride[]
): VectorNetwork {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
let o = 0
const nV = view.getUint32(o, true)
o += 4
const nS = view.getUint32(o, true)
o += 4
const nR = view.getUint32(o, true)
o += 4
const styleMap = new Map<number, StyleOverride>()
if (styleOverrideTable) {
for (const entry of styleOverrideTable) {
styleMap.set(entry.styleID, entry)
}
}
const vertices: VectorVertex[] = []
for (let i = 0; i < nV; i++) {
const styleIdx = view.getUint32(o, true)
o += 4
const x = view.getFloat32(o, true)
o += 4
const y = view.getFloat32(o, true)
o += 4
const override = styleMap.get(styleIdx)
vertices.push({
x,
y,
handleMirroring: (override?.handleMirroring as HandleMirroring | undefined) ?? 'NONE'
})
}
const segments: VectorSegment[] = []
for (let i = 0; i < nS; i++) {
o += 4 // styleOverrideIdx (unused for segments currently)
const start = view.getUint32(o, true)
o += 4
const tsX = view.getFloat32(o, true)
o += 4
const tsY = view.getFloat32(o, true)
o += 4
const end = view.getUint32(o, true)
o += 4
const teX = view.getFloat32(o, true)
o += 4
const teY = view.getFloat32(o, true)
o += 4
segments.push({
start,
end,
tangentStart: { x: tsX, y: tsY },
tangentEnd: { x: teX, y: teY }
})
}
const regions: VectorRegion[] = []
for (let i = 0; i < nR; i++) {
const windingRuleU32 = view.getUint32(o, true)
o += 4
const windingRule: WindingRule = windingRuleU32 === 0 ? 'EVENODD' : 'NONZERO'
const numLoops = view.getUint32(o, true)
o += 4
const loops: number[][] = []
for (let j = 0; j < numLoops; j++) {
const numSegs = view.getUint32(o, true)
o += 4
const loop: number[] = []
for (let k = 0; k < numSegs; k++) {
loop.push(view.getUint32(o, true))
o += 4
}
loops.push(loop)
}
regions.push({ windingRule, loops })
}
return { vertices, segments, regions }
}
/** Build a styleOverrideTable from vertex handleMirroring values.
* Returns a map from handleMirroring value to styleID, plus the table array. */
export function buildStyleOverrideTable(network: VectorNetwork): {
table: StyleOverride[]
mirroringToId: Map<string, number>
} {
const mirroringToId = new Map<string, number>()
const table: StyleOverride[] = []
let nextId = 1
for (const v of network.vertices) {
const hm = v.handleMirroring ?? 'NONE'
if (hm === 'NONE') continue
if (!mirroringToId.has(hm)) {
mirroringToId.set(hm, nextId)
table.push({ styleID: nextId, handleMirroring: hm })
nextId++
}
}
return { table, mirroringToId }
}
export function encodeVectorNetworkBlob(
network: VectorNetwork,
mirroringToId?: Map<string, number>
): Uint8Array {
const { vertices, segments, regions } = network
let regionBytes = 0
for (const region of regions) {
regionBytes += 8 // windingRule + numLoops
for (const loop of region.loops) {
regionBytes += 4 + loop.length * 4 // numSegs + indices
}
}
const totalBytes = 12 + vertices.length * 12 + segments.length * 28 + regionBytes
const buf = new ArrayBuffer(totalBytes)
const view = new DataView(buf)
let o = 0
view.setUint32(o, vertices.length, true)
o += 4
view.setUint32(o, segments.length, true)
o += 4
view.setUint32(o, regions.length, true)
o += 4
for (const v of vertices) {
const hm = v.handleMirroring ?? 'NONE'
const styleIdx = (hm !== 'NONE' && mirroringToId?.get(hm)) || 0
view.setUint32(o, styleIdx, true)
o += 4
view.setFloat32(o, v.x, true)
o += 4
view.setFloat32(o, v.y, true)
o += 4
}
for (const seg of segments) {
view.setUint32(o, 0, true)
o += 4 // styleOverrideIdx
view.setUint32(o, seg.start, true)
o += 4
view.setFloat32(o, seg.tangentStart.x, true)
o += 4
view.setFloat32(o, seg.tangentStart.y, true)
o += 4
view.setUint32(o, seg.end, true)
o += 4
view.setFloat32(o, seg.tangentEnd.x, true)
o += 4
view.setFloat32(o, seg.tangentEnd.y, true)
o += 4
}
for (const region of regions) {
view.setUint32(o, region.windingRule === 'EVENODD' ? 0 : 1, true)
o += 4
view.setUint32(o, region.loops.length, true)
o += 4
for (const loop of region.loops) {
view.setUint32(o, loop.length, true)
o += 4
for (const segIdx of loop) {
view.setUint32(o, segIdx, true)
o += 4
}
}
}
return new Uint8Array(buf)
}
export function vectorNetworkToPath(ck: CanvasKit, network: VectorNetwork): Path[] {
const { vertices, segments, regions } = network

View file

@ -5,3 +5,5 @@ export * from './paint'
export * from './plugin-data'
export * from './style-refs'
export * from './text-values'
export * from './vector-geometry'
export * from './vector-network'

View file

@ -2,7 +2,7 @@ import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import type { GeometryPath, VectorNetwork, WindingRule } from '@open-pencil/scene-graph'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import { decodeVectorNetworkBlob } from '#core/vector'
import { decodeVectorNetworkBlob } from './vector-network'
export function resolveVectorNetwork(nc: NodeChange, blobs: Uint8Array[]): VectorNetwork | null {
const vectorData = nc.vectorData as

View file

@ -0,0 +1,176 @@
import type {
HandleMirroring,
VectorNetwork,
VectorRegion,
VectorSegment,
VectorVertex,
WindingRule
} from '@open-pencil/scene-graph'
export interface StyleOverride {
styleID: number
handleMirroring?: string
}
export function decodeVectorNetworkBlob(
data: Uint8Array,
styleOverrideTable?: StyleOverride[]
): VectorNetwork {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
let offset = 0
const vertexCount = view.getUint32(offset, true)
offset += 4
const segmentCount = view.getUint32(offset, true)
offset += 4
const regionCount = view.getUint32(offset, true)
offset += 4
const styles = new Map<number, StyleOverride>()
for (const entry of styleOverrideTable ?? []) styles.set(entry.styleID, entry)
const vertices: VectorVertex[] = []
for (let index = 0; index < vertexCount; index++) {
const styleIndex = view.getUint32(offset, true)
offset += 4
const x = view.getFloat32(offset, true)
offset += 4
const y = view.getFloat32(offset, true)
offset += 4
vertices.push({
x,
y,
handleMirroring:
(styles.get(styleIndex)?.handleMirroring as HandleMirroring | undefined) ?? 'NONE'
})
}
const segments: VectorSegment[] = []
for (let index = 0; index < segmentCount; index++) {
offset += 4
const start = view.getUint32(offset, true)
offset += 4
const tangentStartX = view.getFloat32(offset, true)
offset += 4
const tangentStartY = view.getFloat32(offset, true)
offset += 4
const end = view.getUint32(offset, true)
offset += 4
const tangentEndX = view.getFloat32(offset, true)
offset += 4
const tangentEndY = view.getFloat32(offset, true)
offset += 4
segments.push({
start,
end,
tangentStart: { x: tangentStartX, y: tangentStartY },
tangentEnd: { x: tangentEndX, y: tangentEndY }
})
}
const regions: VectorRegion[] = []
for (let index = 0; index < regionCount; index++) {
const windingRule: WindingRule = view.getUint32(offset, true) === 0 ? 'EVENODD' : 'NONZERO'
offset += 4
const loopCount = view.getUint32(offset, true)
offset += 4
const loops: number[][] = []
for (let loopIndex = 0; loopIndex < loopCount; loopIndex++) {
const segmentIndexCount = view.getUint32(offset, true)
offset += 4
const loop: number[] = []
for (let segmentIndex = 0; segmentIndex < segmentIndexCount; segmentIndex++) {
loop.push(view.getUint32(offset, true))
offset += 4
}
loops.push(loop)
}
regions.push({ windingRule, loops })
}
return { vertices, segments, regions }
}
export function buildStyleOverrideTable(network: VectorNetwork): {
table: StyleOverride[]
mirroringToId: Map<string, number>
} {
const mirroringToId = new Map<string, number>()
const table: StyleOverride[] = []
let nextId = 1
for (const vertex of network.vertices) {
const mirroring = vertex.handleMirroring ?? 'NONE'
if (mirroring === 'NONE' || mirroringToId.has(mirroring)) continue
mirroringToId.set(mirroring, nextId)
table.push({ styleID: nextId, handleMirroring: mirroring })
nextId++
}
return { table, mirroringToId }
}
export function encodeVectorNetworkBlob(
network: VectorNetwork,
mirroringToId?: Map<string, number>
): Uint8Array {
let regionBytes = 0
for (const region of network.regions) {
regionBytes += 8
for (const loop of region.loops) regionBytes += 4 + loop.length * 4
}
const buffer = new ArrayBuffer(
12 + network.vertices.length * 12 + network.segments.length * 28 + regionBytes
)
const view = new DataView(buffer)
let offset = 0
view.setUint32(offset, network.vertices.length, true)
offset += 4
view.setUint32(offset, network.segments.length, true)
offset += 4
view.setUint32(offset, network.regions.length, true)
offset += 4
for (const vertex of network.vertices) {
const mirroring = vertex.handleMirroring ?? 'NONE'
view.setUint32(offset, mirroring === 'NONE' ? 0 : (mirroringToId?.get(mirroring) ?? 0), true)
offset += 4
view.setFloat32(offset, vertex.x, true)
offset += 4
view.setFloat32(offset, vertex.y, true)
offset += 4
}
for (const segment of network.segments) {
view.setUint32(offset, 0, true)
offset += 4
view.setUint32(offset, segment.start, true)
offset += 4
view.setFloat32(offset, segment.tangentStart.x, true)
offset += 4
view.setFloat32(offset, segment.tangentStart.y, true)
offset += 4
view.setUint32(offset, segment.end, true)
offset += 4
view.setFloat32(offset, segment.tangentEnd.x, true)
offset += 4
view.setFloat32(offset, segment.tangentEnd.y, true)
offset += 4
}
for (const region of network.regions) {
view.setUint32(offset, region.windingRule === 'EVENODD' ? 0 : 1, true)
offset += 4
view.setUint32(offset, region.loops.length, true)
offset += 4
for (const loop of region.loops) {
view.setUint32(offset, loop.length, true)
offset += 4
for (const segmentIndex of loop) {
view.setUint32(offset, segmentIndex, true)
offset += 4
}
}
}
return new Uint8Array(buffer)
}

View file

@ -2,11 +2,14 @@ import { describe, expect, test } from 'bun:test'
import {
applyStyleRefsToFields,
buildStyleOverrideTable,
convertEffects,
convertFills,
convertFontFeatures,
convertLetterSpacing,
convertLineHeight,
decodeVectorNetworkBlob,
encodeVectorNetworkBlob,
mapTextDecoration
} from '../src/node-change'
@ -42,6 +45,28 @@ describe('@open-pencil/fig NodeChange policy', () => {
})
})
test('round-trips vector network blobs with handle mirroring', () => {
const network = {
vertices: [
{ x: 0, y: 0, handleMirroring: 'ANGLE' as const },
{ x: 10, y: 0, handleMirroring: 'NONE' as const }
],
segments: [
{
start: 0,
end: 1,
tangentStart: { x: 0, y: 0 },
tangentEnd: { x: 0, y: 0 }
}
],
regions: []
}
const { table, mirroringToId } = buildStyleOverrideTable(network)
expect(decodeVectorNetworkBlob(encodeVectorNetworkBlob(network, mirroringToId), table)).toEqual(
network
)
})
test('resolves imported style references before SceneGraph conversion', () => {
const fields: Record<string, unknown> = {
styleIdForFill: { guid: { sessionID: 2, localID: 3 } }