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:
parent
db056317a8
commit
c2f3338877
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
176
packages/fig/src/node-change/vector-network.ts
Normal file
176
packages/fig/src/node-change/vector-network.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 } }
|
||||
|
|
|
|||
Loading…
Reference in a new issue