diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 848722d6b..58b7c214d 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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 { diff --git a/packages/core/src/vector/index.ts b/packages/core/src/vector/index.ts index d73726a1b..90d01be35 100644 --- a/packages/core/src/vector/index.ts +++ b/packages/core/src/vector/index.ts @@ -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() - 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 -} { - const mirroringToId = new Map() - 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 -): 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 diff --git a/packages/fig/src/node-change/index.ts b/packages/fig/src/node-change/index.ts index a4ce75f5e..19b3dc738 100644 --- a/packages/fig/src/node-change/index.ts +++ b/packages/fig/src/node-change/index.ts @@ -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' diff --git a/packages/core/src/kiwi/fig/node-change/vector-geometry.ts b/packages/fig/src/node-change/vector-geometry.ts similarity index 97% rename from packages/core/src/kiwi/fig/node-change/vector-geometry.ts rename to packages/fig/src/node-change/vector-geometry.ts index f9956374a..36ff7dd9c 100644 --- a/packages/core/src/kiwi/fig/node-change/vector-geometry.ts +++ b/packages/fig/src/node-change/vector-geometry.ts @@ -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 diff --git a/packages/fig/src/node-change/vector-network.ts b/packages/fig/src/node-change/vector-network.ts new file mode 100644 index 000000000..cfdd5730a --- /dev/null +++ b/packages/fig/src/node-change/vector-network.ts @@ -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() + 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 +} { + const mirroringToId = new Map() + 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 +): 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) +} diff --git a/packages/fig/tests/node-change.test.ts b/packages/fig/tests/node-change.test.ts index a754ed673..0f3d9894e 100644 --- a/packages/fig/tests/node-change.test.ts +++ b/packages/fig/tests/node-change.test.ts @@ -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 = { styleIdForFill: { guid: { sessionID: 2, localID: 3 } }