fix(figma-api): match Figma vector editing (#444)

- Support vectorPaths, vectorNetwork, and async network assignment
- Normalize geometry bounds and preserve winding and region paint semantics
- Validate network topology and expose handle mirroring only on vectors

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
rcoenen 2026-08-02 15:32:16 -04:00 committed by GitHub
parent db15d8d583
commit 2949bd648f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 935 additions and 85 deletions

View file

@ -4,6 +4,7 @@
### Fixed
- Match Figma Plugin API vector path and network editing, including bounds, winding rules, region fills, validation, and handle mirroring. (#444)
- Let AI and MCP tools create arbitrary vectors from SVG path data without leaving blank layers after invalid input. (#440)
- Show stroke colors and weights in AI visual descriptions. (#447)
- Stop warning AI agents that supported inline SVG attributes were ignored. (#445)

View file

@ -1,29 +1,317 @@
import { raw, type NodeProxyInternals, type ProxyThis } from '#core/figma-api/accessor-utils'
import {
mergeVectorNetworks,
normalizeVectorNetwork,
transformVectorNetwork,
validateVectorNetwork
} from '@open-pencil/scene-graph'
import type {
Fill,
GeometryPath,
HandleMirroring,
VectorNetwork as SceneVectorNetwork,
VectorSegment,
VectorVertex,
WindingRule
} from '@open-pencil/scene-graph'
import { copyFills } from '@open-pencil/scene-graph/copy'
import { parsePluginVectorPath } from '@open-pencil/scene-graph/parse-path'
import {
raw,
updateNode,
type NodeProxyInternals,
type ProxyThis
} from '#core/figma-api/accessor-utils'
import { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from '#core/io/formats/svg/paths'
import { computeAccurateBounds } from '#core/vector/curve-math'
import { regenerateFillGeometry } from '#core/vector/fill-geometry'
export interface FigmaVectorPath {
readonly windingRule: 'NONZERO' | 'EVENODD'
readonly windingRule: WindingRule | 'NONE'
readonly data: string
}
export function installVectorNodeProxyAccessors(
prototype: object,
internals: NodeProxyInternals
interface FigmaVectorRegion {
readonly windingRule: WindingRule
readonly loops: ReadonlyArray<ReadonlyArray<number>>
readonly fills?: readonly Fill[]
readonly fillStyleId?: string
}
export interface FigmaVectorNetwork {
readonly vertices: readonly VectorVertex[]
readonly segments: readonly (Omit<VectorSegment, 'tangentStart' | 'tangentEnd'> & {
readonly tangentStart?: VectorSegment['tangentStart']
readonly tangentEnd?: VectorSegment['tangentEnd']
})[]
readonly regions?: readonly FigmaVectorRegion[]
}
const EMPTY_NETWORK: SceneVectorNetwork = { vertices: [], segments: [], regions: [] }
const HANDLE_MIRRORING_VALUES = new Set<HandleMirroring>(['NONE', 'ANGLE', 'ANGLE_AND_LENGTH'])
function networkFromVectorPaths(paths: readonly FigmaVectorPath[]): SceneVectorNetwork {
const networks: SceneVectorNetwork[] = []
for (let index = 0; index < paths.length; index++) {
const path: unknown = paths[index]
if (typeof path !== 'object' || path === null || !('data' in path)) {
throw new TypeError(`vectorPaths[${index}].data must be a string`)
}
const data = path.data
if (typeof data !== 'string') {
throw new TypeError(`vectorPaths[${index}].data must be a string`)
}
const windingRule: unknown = 'windingRule' in path ? path.windingRule : undefined
if (windingRule !== 'NONZERO' && windingRule !== 'EVENODD' && windingRule !== 'NONE') {
throw new TypeError(`vectorPaths[${index}].windingRule is invalid`)
}
const parsed = parsePluginVectorPath(data, windingRule)
if (!parsed.ok) throw new TypeError(`Invalid vector path: ${parsed.error}`)
if (parsed.network.segments.length > 0) networks.push(parsed.network)
}
return networks.length > 0 ? mergeVectorNetworks(networks) : structuredClone(EMPTY_NETWORK)
}
function normalizeInputNetwork(value: FigmaVectorNetwork): SceneVectorNetwork {
const regions = value.regions ?? []
return normalizeVectorNetwork({
vertices: value.vertices.map((vertex) => ({ ...vertex })),
segments: value.segments.map((segment) => ({
...segment,
tangentStart: {
x: segment.tangentStart?.x ?? 0,
y: segment.tangentStart?.y ?? 0
},
tangentEnd: {
x: segment.tangentEnd?.x ?? 0,
y: segment.tangentEnd?.y ?? 0
}
})),
regions: regions.map((region) => ({
windingRule: region.windingRule,
loops: region.loops.map((loop) => [...loop])
}))
})
}
function validateRegionPaintMetadata(regions: readonly FigmaVectorRegion[]): void {
for (let index = 0; index < regions.length; index++) {
const region = regions[index]
if (region.fills !== undefined && !Array.isArray(region.fills)) {
throw new TypeError(`vectorNetwork.regions[${index}].fills must be an array`)
}
if (region.fillStyleId !== undefined && typeof region.fillStyleId !== 'string') {
throw new TypeError(`vectorNetwork.regions[${index}].fillStyleId must be a string`)
}
}
}
function geometryForRegions(regions: readonly FigmaVectorRegion[]): GeometryPath[] {
if (!regions.some((region) => region.fills !== undefined || region.fillStyleId !== undefined)) {
return []
}
return regions.map((region) => ({
windingRule: region.windingRule,
commandsBlob: new Uint8Array(),
fills: region.fills ? copyFills([...region.fills]) : [],
fillStyleId: region.fillStyleId ?? ''
}))
}
function setGeometry(
target: ProxyThis,
internals: NodeProxyInternals,
network: SceneVectorNetwork,
fillGeometry: GeometryPath[]
): void {
Object.defineProperty(prototype, 'vectorPaths', {
get(this: ProxyThis): readonly FigmaVectorPath[] {
const node = raw(this, internals)
const paths =
node.fillGeometry.length > 0
? node.fillGeometry.map((geometry) => ({
windingRule: geometry.windingRule,
data: geometryBlobToSVGPath(geometry.commandsBlob)
}))
: (node.vectorNetwork ? vectorNetworkToSVGPaths(node.vectorNetwork) : []).map((data) => ({
windingRule: 'NONZERO' as const,
data
}))
return Object.freeze(paths.map((path) => Object.freeze(path)))
const node = raw(target, internals)
const bounds = computeAccurateBounds(network)
const hasGeometry = network.vertices.length > 0
const normalized = hasGeometry
? transformVectorNetwork([1, 0, -bounds.x, 0, 1, -bounds.y, 0, 0, 1], network)
: structuredClone(EMPTY_NETWORK)
const normalizedGeometry =
fillGeometry.length > 0 ? regenerateFillGeometry(normalized, fillGeometry) : []
updateNode(target, internals, {
x: hasGeometry ? node.x + bounds.x : node.x,
y: hasGeometry ? node.y + bounds.y : node.y,
width: hasGeometry ? bounds.width : 0,
height: hasGeometry ? bounds.height : 0,
vectorNetwork: normalized,
fillGeometry: normalizedGeometry,
strokeGeometry: []
})
}
function assignVectorNetwork(
target: ProxyThis,
internals: NodeProxyInternals,
value: FigmaVectorNetwork
): void {
const errors = validateVectorNetwork(value)
if (errors.length > 0) throw new TypeError(`Invalid vectorNetwork: ${errors.join('; ')}`)
validateRegionPaintMetadata(value.regions ?? [])
const network = normalizeInputNetwork(value)
setGeometry(target, internals, network, geometryForRegions(value.regions ?? []))
}
function vectorPathsForNetwork(network: SceneVectorNetwork): FigmaVectorPath[] {
if (network.segments.length === 0) return []
const regions = (network as Partial<SceneVectorNetwork>).regions ?? []
if (regions.length === 0) {
return vectorNetworkToSVGPaths({ ...network, regions }, null).map((data) => ({
windingRule: 'NONE',
data
}))
}
const paths: FigmaVectorPath[] = []
const usedSegments = new Set<number>()
for (const region of regions) {
for (const loop of region.loops) for (const segmentIndex of loop) usedSegments.add(segmentIndex)
const data = vectorNetworkToSVGPaths({ ...network, regions: [region] }, null)[0]
if (data) paths.push({ windingRule: region.windingRule, data })
}
const remainingSegments = network.segments.filter((_, index) => !usedSegments.has(index))
if (remainingSegments.length > 0) {
const data = vectorNetworkToSVGPaths(
{
vertices: network.vertices,
segments: remainingSegments,
regions: []
},
null
)[0]
if (data) paths.push({ windingRule: 'NONE', data })
}
return paths
}
function readVectorPaths(
target: ProxyThis,
internals: NodeProxyInternals
): readonly FigmaVectorPath[] {
const node = raw(target, internals)
const paths =
node.vectorNetwork && node.vectorNetwork.segments.length > 0
? vectorPathsForNetwork(node.vectorNetwork)
: node.fillGeometry.map((geometry) => ({
windingRule: geometry.windingRule,
data: geometryBlobToSVGPath(geometry.commandsBlob, null)
}))
return Object.freeze(paths.map((path) => Object.freeze(path)))
}
function readVectorNetwork(target: ProxyThis, internals: NodeProxyInternals): FigmaVectorNetwork {
const node = raw(target, internals)
const network = node.vectorNetwork ?? EMPTY_NETWORK
const vertices = network.vertices.map((vertex) =>
Object.freeze({
...vertex,
strokeCap: vertex.strokeCap ?? node.strokeCap,
strokeJoin: vertex.strokeJoin ?? node.strokeJoin,
cornerRadius: vertex.cornerRadius ?? 0,
handleMirroring: vertex.handleMirroring ?? 'NONE'
})
)
const segments = network.segments.map((segment) =>
Object.freeze({
...segment,
tangentStart: Object.freeze({ ...segment.tangentStart }),
tangentEnd: Object.freeze({ ...segment.tangentEnd })
})
)
const sourceRegions = (network as Partial<SceneVectorNetwork>).regions ?? []
const regions = sourceRegions.map((region, index) => {
const geometry = node.fillGeometry.at(index)
return Object.freeze({
windingRule: region.windingRule,
loops: Object.freeze(region.loops.map((loop) => Object.freeze([...loop]))),
fillStyleId: geometry?.fillStyleId ?? '',
fills: Object.freeze(copyFills(geometry?.fills ?? []))
})
})
return Object.freeze({
vertices: Object.freeze(vertices),
segments: Object.freeze(segments),
regions: Object.freeze(regions)
})
}
function readHandleMirroring(
target: ProxyThis,
internals: NodeProxyInternals,
mixed: symbol
): HandleMirroring | symbol {
const node = raw(target, internals)
const values = new Set(
(node.vectorNetwork?.vertices ?? []).map((vertex) => vertex.handleMirroring ?? 'NONE')
)
if (values.size === 0) return node.handleMirroring
if (values.size > 1) return mixed
return values.values().next().value ?? node.handleMirroring
}
export function installVectorNodeProxyAccessors(
target: object,
internals: NodeProxyInternals,
mixed: symbol
): void {
Object.defineProperties(target, {
vectorPaths: {
get(this: ProxyThis): readonly FigmaVectorPath[] {
return readVectorPaths(this, internals)
},
set(this: ProxyThis, value: readonly FigmaVectorPath[]) {
if (!Array.isArray(value)) throw new TypeError('vectorPaths must be an array')
setGeometry(this, internals, networkFromVectorPaths(value), [])
},
enumerable: true,
configurable: true
},
vectorNetwork: {
get(this: ProxyThis): FigmaVectorNetwork {
return readVectorNetwork(this, internals)
},
set(this: ProxyThis, value: FigmaVectorNetwork) {
assignVectorNetwork(this, internals, value)
},
enumerable: true,
configurable: true
},
setVectorNetworkAsync: {
value(this: ProxyThis, value: FigmaVectorNetwork): Promise<void> {
return Promise.resolve().then(() => assignVectorNetwork(this, internals, value))
},
enumerable: true,
configurable: true
},
handleMirroring: {
get(this: ProxyThis): HandleMirroring | symbol {
return readHandleMirroring(this, internals, mixed)
},
set(this: ProxyThis, value: HandleMirroring) {
if (!HANDLE_MIRRORING_VALUES.has(value)) {
throw new TypeError(`Invalid handleMirroring: ${String(value)}`)
}
const node = raw(this, internals)
updateNode(this, internals, {
handleMirroring: value,
vectorNetwork: node.vectorNetwork
? {
...node.vectorNetwork,
vertices: node.vectorNetwork.vertices.map((vertex) => ({
...vertex,
handleMirroring: value
}))
}
: null
})
},
enumerable: true,
configurable: true
}
})
}

View file

@ -20,7 +20,11 @@ import type { OkHCLColor, OkHCLPayload } from '#core/color/okhcl'
import { installBasicNodeProxyAccessors } from './accessors/basic'
import { installLayoutNodeProxyAccessors } from './accessors/layout'
import { installVariableModeNodeProxyAccessors } from './accessors/variables'
import { installVectorNodeProxyAccessors, type FigmaVectorPath } from './accessors/vector'
import {
installVectorNodeProxyAccessors,
type FigmaVectorNetwork,
type FigmaVectorPath
} from './accessors/vector'
import { installVisualNodeProxyAccessors } from './accessors/visual'
import type { FigmaFontName } from './fonts'
import * as PluginData from './plugin-data'
@ -105,7 +109,10 @@ export class FigmaNodeProxy {
declare maxWidth: number | null
declare minHeight: number | null
declare maxHeight: number | null
declare readonly vectorPaths: readonly FigmaVectorPath[]
declare vectorPaths: readonly FigmaVectorPath[]
declare vectorNetwork: FigmaVectorNetwork
declare setVectorNetworkAsync: (vectorNetwork: FigmaVectorNetwork) => Promise<void>
declare handleMirroring: SceneNode['handleMirroring'] | typeof MIXED
declare readonly explicitVariableModes: Readonly<Record<string, string>>
declare readonly resolvedVariableModes: Readonly<Record<string, string>>
@ -113,6 +120,13 @@ export class FigmaNodeProxy {
this[INTERNAL_ID] = id
this[INTERNAL_GRAPH] = graph
this[INTERNAL_API] = api
if (graph.getNode(id)?.type === 'VECTOR') {
installVectorNodeProxyAccessors(
this,
{ id: INTERNAL_ID, graph: INTERNAL_GRAPH, api: INTERNAL_API },
MIXED
)
}
}
private _raw(): SceneNode {
@ -559,4 +573,3 @@ const proxyInternals = {
installLayoutNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
installVariableModeNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)
installVectorNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals)

View file

@ -19,7 +19,11 @@ export function round(n: number, decimals = 2): number {
return Math.round(n * factor) / factor
}
export function geometryBlobToSVGPath(blob: Uint8Array): string {
function coordinate(value: number, decimals: number | null): number {
return decimals === null ? value : round(value, decimals)
}
export function geometryBlobToSVGPath(blob: Uint8Array, decimals: number | null = 2): string {
if (blob.length === 0) return ''
const dv = new DataView(blob.buffer, blob.byteOffset, blob.byteLength)
let o = 0
@ -32,35 +36,35 @@ export function geometryBlobToSVGPath(blob: Uint8Array): string {
parts.push('Z')
break
case CMD_MOVE_TO: {
const x = round(dv.getFloat32(o, true))
const y = round(dv.getFloat32(o + 4, true))
const x = coordinate(dv.getFloat32(o, true), decimals)
const y = coordinate(dv.getFloat32(o + 4, true), decimals)
o += 8
parts.push(`M${x} ${y}`)
break
}
case CMD_LINE_TO: {
const x = round(dv.getFloat32(o, true))
const y = round(dv.getFloat32(o + 4, true))
const x = coordinate(dv.getFloat32(o, true), decimals)
const y = coordinate(dv.getFloat32(o + 4, true), decimals)
o += 8
parts.push(`L${x} ${y}`)
break
}
case CMD_QUAD_TO: {
const x1 = round(dv.getFloat32(o, true))
const y1 = round(dv.getFloat32(o + 4, true))
const x = round(dv.getFloat32(o + 8, true))
const y = round(dv.getFloat32(o + 12, true))
const x1 = coordinate(dv.getFloat32(o, true), decimals)
const y1 = coordinate(dv.getFloat32(o + 4, true), decimals)
const x = coordinate(dv.getFloat32(o + 8, true), decimals)
const y = coordinate(dv.getFloat32(o + 12, true), decimals)
o += 16
parts.push(`Q${x1} ${y1} ${x} ${y}`)
break
}
case CMD_CUBIC_TO: {
const x1 = round(dv.getFloat32(o, true))
const y1 = round(dv.getFloat32(o + 4, true))
const x2 = round(dv.getFloat32(o + 8, true))
const y2 = round(dv.getFloat32(o + 12, true))
const x = round(dv.getFloat32(o + 16, true))
const y = round(dv.getFloat32(o + 20, true))
const x1 = coordinate(dv.getFloat32(o, true), decimals)
const y1 = coordinate(dv.getFloat32(o + 4, true), decimals)
const x2 = coordinate(dv.getFloat32(o + 8, true), decimals)
const y2 = coordinate(dv.getFloat32(o + 12, true), decimals)
const x = coordinate(dv.getFloat32(o + 16, true), decimals)
const y = coordinate(dv.getFloat32(o + 20, true), decimals)
o += 24
parts.push(`C${x1} ${y1} ${x2} ${y2} ${x} ${y}`)
break
@ -73,7 +77,12 @@ export function geometryBlobToSVGPath(blob: Uint8Array): string {
return parts.join('')
}
function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boolean): string {
function segmentToSVG(
seg: VectorSegment,
vertices: VectorVertex[],
forward: boolean,
decimals: number | null
): string {
const start = forward ? vertices[seg.start] : vertices[seg.end]
const end = forward ? vertices[seg.end] : vertices[seg.start]
const ts = forward ? seg.tangentStart : { x: -seg.tangentEnd.x, y: -seg.tangentEnd.y }
@ -86,42 +95,135 @@ function segmentToSVG(seg: VectorSegment, vertices: VectorVertex[], forward: boo
Math.abs(te.y) < 0.001
if (isStraight) {
return `L${round(end.x)} ${round(end.y)}`
return `L${coordinate(end.x, decimals)} ${coordinate(end.y, decimals)}`
}
const cp1x = round(start.x + ts.x)
const cp1y = round(start.y + ts.y)
const cp2x = round(end.x + te.x)
const cp2y = round(end.y + te.y)
return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${round(end.x)} ${round(end.y)}`
const cp1x = coordinate(start.x + ts.x, decimals)
const cp1y = coordinate(start.y + ts.y, decimals)
const cp2x = coordinate(end.x + te.x, decimals)
const cp2y = coordinate(end.y + te.y, decimals)
return `C${cp1x} ${cp1y} ${cp2x} ${cp2y} ${coordinate(end.x, decimals)} ${coordinate(end.y, decimals)}`
}
export function vectorNetworkToSVGPaths(network: VectorNetwork): string[] {
function traceOrderedSegments(
segmentIndices: readonly number[],
segments: VectorSegment[],
vertices: VectorVertex[],
decimals: number | null
): string {
if (segmentIndices.length === 0) return ''
const first = segments[segmentIndices[0]]
const second = segmentIndices.length > 1 ? segments[segmentIndices[1]] : null
// Start at the first segment endpoint that does not connect to the second segment.
const secondConnectsStart = second?.start === first.start || second?.end === first.start
const secondConnectsEnd = second?.start === first.end || second?.end === first.end
const firstForward = !second || secondConnectsEnd || !secondConnectsStart
const startIndex = firstForward ? first.start : first.end
let currentIndex = startIndex
const parts = [
`M${coordinate(vertices[startIndex].x, decimals)} ${coordinate(vertices[startIndex].y, decimals)}`
]
for (const segmentIndex of segmentIndices) {
const segment = segments[segmentIndex]
const isConnected = segment.start === currentIndex || segment.end === currentIndex
const forward = segment.start === currentIndex || !isConnected
if (!isConnected) {
const segmentStart = forward ? segment.start : segment.end
parts.push(
`M${coordinate(vertices[segmentStart].x, decimals)} ${coordinate(vertices[segmentStart].y, decimals)}`
)
currentIndex = segmentStart
}
parts.push(segmentToSVG(segment, vertices, forward, decimals))
currentIndex = forward ? segment.end : segment.start
}
if (parts.length === segmentIndices.length + 1 && currentIndex === startIndex) parts.push('Z')
return parts.join('')
}
type SegmentAdjacency = ReadonlyMap<number, readonly number[]>
function buildSegmentAdjacency(segments: VectorSegment[]): SegmentAdjacency {
const adjacency = new Map<number, number[]>()
for (let index = 0; index < segments.length; index++) {
const segment = segments[index]
for (const vertexIndex of [segment.start, segment.end]) {
const incidentSegments = adjacency.get(vertexIndex)
if (incidentSegments) incidentSegments.push(index)
else adjacency.set(vertexIndex, [index])
}
}
return adjacency
}
function findConnectedSegment(
remaining: ReadonlySet<number>,
adjacency: SegmentAdjacency,
vertexIndex: number
): number | undefined {
return adjacency.get(vertexIndex)?.find((index) => remaining.has(index))
}
function extendSegmentChain(
chain: number[],
startVertex: number,
remaining: Set<number>,
segments: VectorSegment[],
adjacency: SegmentAdjacency
): void {
let currentIndex = startVertex
let nextIndex = findConnectedSegment(remaining, adjacency, currentIndex)
while (nextIndex !== undefined) {
chain.push(nextIndex)
remaining.delete(nextIndex)
const segment = segments[nextIndex]
currentIndex = segment.start === currentIndex ? segment.end : segment.start
nextIndex = findConnectedSegment(remaining, adjacency, currentIndex)
}
}
function unfilledSegmentsToPath(network: VectorNetwork, decimals: number | null): string {
const { vertices, segments } = network
const remaining = new Set(segments.map((_, index) => index))
const adjacency = buildSegmentAdjacency(segments)
const parts: string[] = []
while (remaining.size > 0) {
const firstIndex = remaining.values().next().value
if (firstIndex === undefined) break
remaining.delete(firstIndex)
const first = segments[firstIndex]
const backward: number[] = []
extendSegmentChain(backward, first.start, remaining, segments, adjacency)
backward.reverse()
const forward: number[] = []
extendSegmentChain(forward, first.end, remaining, segments, adjacency)
parts.push(
traceOrderedSegments([...backward, firstIndex, ...forward], segments, vertices, decimals)
)
}
return parts.join('')
}
export function vectorNetworkToSVGPaths(
network: VectorNetwork,
decimals: number | null = 2
): string[] {
const { vertices, segments, regions } = network
if (regions.length > 0) {
return regions.map((region) => {
const parts: string[] = []
for (const loop of region.loops) {
if (loop.length === 0) continue
const firstSeg = segments[loop[0]]
parts.push(`M${round(vertices[firstSeg.start].x)} ${round(vertices[firstSeg.start].y)}`)
for (const segIdx of loop) {
parts.push(segmentToSVG(segments[segIdx], vertices, true))
}
parts.push('Z')
}
return parts.join('')
})
return regions.map((region) =>
region.loops.map((loop) => traceOrderedSegments(loop, segments, vertices, decimals)).join('')
)
}
const parts: string[] = []
for (const seg of segments) {
parts.push(`M${round(vertices[seg.start].x)} ${round(vertices[seg.start].y)}`)
parts.push(segmentToSVG(seg, vertices, true))
}
return parts.length > 0 ? [parts.join('')] : []
const path = unfilledSegmentsToPath(network, decimals)
return path ? [path] : []
}
export function makePolygonPoints(node: SceneNode): string {

View file

@ -90,6 +90,7 @@ export function createDefaultNode(
layoutGrow: 0,
layoutAlignSelf: 'AUTO',
vectorNetwork: null,
handleMirroring: 'NONE',
fillGeometry: [],
strokeGeometry: [],
arcData: null,

View file

@ -8,6 +8,14 @@ interface SubPath {
closed: boolean
}
export type VectorPathWindingRule = WindingRule | 'NONE'
export type SVGPathParseResult = { ok: true; network: VectorNetwork } | { ok: false; error: string }
interface ParseOptions {
includeOpenRegions: boolean
strictCommands: boolean
}
/**
* Parse an SVG path `d` attribute into a VectorNetwork.
*
@ -15,6 +23,29 @@ interface SubPath {
* (arcs → cubics via `.unarc()`, smooth curves → explicit via `.unshort()`).
*/
export function parseSVGPath(d: string, windingRule: WindingRule = 'NONZERO'): VectorNetwork {
const parsed = parsePath(d, windingRule, {
includeOpenRegions: false,
strictCommands: false
})
return parsed.ok ? parsed.network : { vertices: [], segments: [], regions: [] }
}
/** Parse the strict absolute command subset accepted by Figma's VectorPath API. */
export function parsePluginVectorPath(
d: string,
windingRule: VectorPathWindingRule
): SVGPathParseResult {
return parsePath(d, windingRule, {
includeOpenRegions: windingRule !== 'NONE',
strictCommands: true
})
}
function parsePath(
d: string,
windingRule: VectorPathWindingRule,
options: ParseOptions
): SVGPathParseResult {
const vertices: VectorVertex[] = []
const segments: VectorSegment[] = []
const subPaths: SubPath[] = []
@ -77,7 +108,18 @@ export function parseSVGPath(d: string, windingRule: WindingRule = 'NONZERO'): V
}
const parsed = svgpath(d)
if ('err' in parsed && parsed.err) return { vertices, segments, regions: [] }
const parseError = 'err' in parsed && typeof parsed.err === 'string' ? parsed.err : null
if (parseError) return { ok: false, error: parseError }
if (options.strictCommands) {
const unsupportedCommands = new Set<string>()
parsed.iterate((segment) => {
if (!['M', 'L', 'Q', 'C', 'Z'].includes(segment[0])) unsupportedCommands.add(segment[0])
})
const unsupportedCommand = unsupportedCommands.values().next().value
if (unsupportedCommand !== undefined) {
return { ok: false, error: `Unsupported path command ${unsupportedCommand}` }
}
}
const normalized = parsed.abs().unshort().unarc()
normalized.iterate((seg) => {
@ -132,13 +174,18 @@ export function parseSVGPath(d: string, windingRule: WindingRule = 'NONZERO'): V
})
const regions: VectorRegion[] = []
const closedPaths = subPaths.filter((sp) => sp.closed && sp.segmentIndices.length > 0)
if (closedPaths.length > 0) {
const regionPaths = subPaths.filter(
(subPath) =>
windingRule !== 'NONE' &&
subPath.segmentIndices.length > 0 &&
(subPath.closed || options.includeOpenRegions)
)
if (regionPaths.length > 0 && windingRule !== 'NONE') {
regions.push({
windingRule,
loops: closedPaths.map((sp) => sp.segmentIndices)
loops: regionPaths.map((subPath) => subPath.segmentIndices)
})
}
return { vertices, segments, regions }
return { ok: true, network: { vertices, segments, regions } }
}

View file

@ -75,6 +75,8 @@ export interface GeometryPath {
commandsBlob: Uint8Array
/** Resolved paints for geometry using a format-specific style override. */
fills?: Fill[]
/** Shared fill style attached to this geometry region, when available. */
fillStyleId?: string
}
export type NodeType =
@ -438,6 +440,7 @@ export interface SceneNode {
layoutAlignSelf: LayoutAlignSelf
vectorNetwork: VectorNetwork | null
handleMirroring: HandleMirroring
booleanOperation?: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE'
fillGeometry: GeometryPath[]
strokeGeometry: GeometryPath[]

View file

@ -104,10 +104,18 @@ export function validateVectorNetwork(value: unknown): string[] {
const errors: string[] = []
validateVertices(value.vertices, errors)
validateSegments(value.segments, value.vertices.length, errors)
if (Array.isArray(value.regions)) {
validateRegions(value.regions, value.segments.length, errors)
} else {
errors.push('regions must be an array')
const typedSegments = value.segments.filter(isSegmentRecord)
if (value.regions !== undefined) {
if (Array.isArray(value.regions)) {
validateRegions(
value.regions,
value.segments.length,
typedSegments.length === value.segments.length ? typedSegments : null,
errors
)
} else {
errors.push('regions must be an array when provided')
}
}
return errors
}
@ -154,7 +162,12 @@ function validateSegmentTangents(
}
}
function validateRegions(regions: unknown[], segmentCount: number, errors: string[]): void {
function validateRegions(
regions: unknown[],
segmentCount: number,
segments: Array<Record<'start' | 'end', number>> | null,
errors: string[]
): void {
for (let regionIndex = 0; regionIndex < regions.length; regionIndex++) {
const region = regions[regionIndex]
if (!isRecord(region) || !Array.isArray(region.loops)) {
@ -164,7 +177,10 @@ function validateRegions(regions: unknown[], segmentCount: number, errors: strin
if (region.windingRule !== 'NONZERO' && region.windingRule !== 'EVENODD') {
errors.push(`region[${regionIndex}]: windingRule must be NONZERO or EVENODD`)
}
validateRegionLoops(region.loops, regionIndex, segmentCount, errors)
if (region.loops.length === 0) {
errors.push(`region[${regionIndex}]: loops must contain at least one loop`)
}
validateRegionLoops(region.loops, regionIndex, segmentCount, segments, errors)
}
}
@ -172,6 +188,7 @@ function validateRegionLoops(
loops: unknown[],
regionIndex: number,
segmentCount: number,
segments: Array<Record<'start' | 'end', number>> | null,
errors: string[]
): void {
for (let loopIndex = 0; loopIndex < loops.length; loopIndex++) {
@ -180,16 +197,60 @@ function validateRegionLoops(
errors.push(`region[${regionIndex}].loop[${loopIndex}] must be an array`)
continue
}
if (loop.length === 0) {
errors.push(`region[${regionIndex}].loop[${loopIndex}] must contain at least one segment`)
continue
}
const segmentIndices: number[] = []
for (const segmentIndex of loop) {
if (!isInteger(segmentIndex) || segmentIndex < 0 || segmentIndex >= segmentCount) {
errors.push(
`region[${regionIndex}].loop[${loopIndex}]: segment index ${String(segmentIndex)} out of range`
)
} else {
segmentIndices.push(segmentIndex)
}
}
if (segmentIndices.length !== loop.length) continue
if (new Set(segmentIndices).size !== segmentIndices.length) {
errors.push(`region[${regionIndex}].loop[${loopIndex}] must not repeat segments`)
continue
}
if (segments && !formsContinuousChain(segmentIndices, segments)) {
errors.push(`region[${regionIndex}].loop[${loopIndex}] segments must form a continuous chain`)
}
}
}
function formsContinuousChain(
indices: number[],
segments: Array<Record<'start' | 'end', number>>
): boolean {
if (indices.length <= 1) return true
const first = segments[indices[0]]
return followsChain(indices, segments, first.end) || followsChain(indices, segments, first.start)
}
function followsChain(
indices: number[],
segments: Array<Record<'start' | 'end', number>>,
initialEnd: number
): boolean {
let current = initialEnd
for (let index = 1; index < indices.length; index++) {
const segment = segments[indices[index]]
if (segment.start === current) current = segment.end
else if (segment.end === current) current = segment.start
else return false
}
return true
}
function isSegmentRecord(value: unknown): value is Record<'start' | 'end', number> {
return isRecord(value) && isInteger(value.start) && isInteger(value.end)
}
function isFiniteVector(value: unknown): value is Vector {
return (
isRecord(value) &&
@ -208,12 +269,16 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
export type NormalizableVectorNetwork = Omit<VectorNetwork, 'regions'> & {
regions?: VectorNetwork['regions']
}
/**
* Ensure every segment has tangentStart/tangentEnd.
* Ensure every segment has tangentStart/tangentEnd and a regions array.
* Missing tangents default to {x:0, y:0} (straight line segments).
* Use at system boundaries where input may come from JSON/MCP.
*/
export function normalizeVectorNetwork(vn: VectorNetwork): VectorNetwork {
export function normalizeVectorNetwork(vn: NormalizableVectorNetwork): VectorNetwork {
const ZERO: Vector = { x: 0, y: 0 }
return {
vertices: vn.vertices,
@ -223,6 +288,6 @@ export function normalizeVectorNetwork(vn: VectorNetwork): VectorNetwork {
tangentStart: (s as Partial<VectorSegment>).tangentStart ?? { ...ZERO },
tangentEnd: (s as Partial<VectorSegment>).tangentEnd ?? { ...ZERO }
})),
regions: vn.regions
regions: vn.regions ?? []
}
}

View file

@ -4,6 +4,8 @@ import { encodePathCommandsBlob } from '@open-pencil/fig/node-change'
import { createAPI } from '../helpers'
const CURVED_PATH = 'M10 20 C20 0 40 40 50 20 Z'
describe('vector paths', () => {
test('exposes imported geometry as Figma SVG paths', () => {
const api = createAPI()
@ -25,4 +27,221 @@ describe('vector paths', () => {
expect(Object.isFrozen(vector.vectorPaths)).toBe(true)
expect(Object.isFrozen(vector.vectorPaths[0])).toBe(true)
})
test('matches Figma defaults and exposes vector properties only on vectors', () => {
const api = createAPI()
const vector = api.createVector()
const rectangle = api.createRectangle()
expect(vector.vectorPaths).toEqual([])
expect(vector.vectorNetwork).toEqual({ vertices: [], segments: [], regions: [] })
expect(vector.handleMirroring).toBe('NONE')
expect(Object.isFrozen(vector.vectorNetwork)).toBe(true)
expect('vectorPaths' in rectangle).toBe(false)
expect('vectorNetwork' in rectangle).toBe(false)
})
test('normalizes assigned path geometry and updates bounds like Figma', () => {
const api = createAPI()
const vector = api.createVector()
vector.vectorPaths = [{ windingRule: 'EVENODD', data: CURVED_PATH }]
expect(vector.x).toBeCloseTo(10)
expect(vector.y).toBeCloseTo(14.2265, 3)
expect(vector.width).toBeCloseTo(40)
expect(vector.height).toBeCloseTo(11.547, 3)
expect(vector.vectorPaths[0].windingRule).toBe('EVENODD')
expect(vector.vectorNetwork.vertices[0]?.x).toBeCloseTo(0)
expect(vector.vectorNetwork.vertices[0]?.y).toBeCloseTo(5.7735, 3)
vector.vectorPaths = []
expect(vector.width).toBe(0)
expect(vector.height).toBe(0)
expect(vector.vectorPaths).toEqual([])
expect(vector.vectorNetwork.vertices).toEqual([])
})
test('preserves filled, open, and unfilled path winding semantics', () => {
const api = createAPI()
const vector = api.createVector()
vector.vectorPaths = [
{ windingRule: 'EVENODD', data: 'M 0 0 L 10 0 L 0 10 Z' },
{ windingRule: 'NONZERO', data: 'M 20 20 L 30 30' },
{ windingRule: 'NONE', data: 'M 40 40 L 50 40 L 40 50 Z' }
]
expect(vector.vectorPaths.map((path) => path.windingRule)).toEqual([
'EVENODD',
'NONZERO',
'NONE'
])
expect(vector.vectorPaths[1]?.data.endsWith('Z')).toBe(false)
expect(vector.vectorPaths[2]?.data.endsWith('Z')).toBe(true)
expect(vector.vectorNetwork.regions?.map((region) => region.windingRule)).toEqual([
'EVENODD',
'NONZERO'
])
})
test('accepts optional regions, freezes readback, and preserves region fills', () => {
const api = createAPI()
const vector = api.createVector()
vector.vectorNetwork = {
vertices: [
{ x: 10, y: 20 },
{ x: 30, y: 20 },
{ x: 10, y: 40 }
],
segments: [
{ start: 0, end: 1 },
{ start: 1, end: 2 },
{ start: 2, end: 0 }
],
regions: [
{
windingRule: 'NONZERO',
loops: [[0, 1, 2]],
fills: [
{
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true
}
]
}
]
}
const network = vector.vectorNetwork
expect(vector.x).toBe(10)
expect(vector.y).toBe(20)
expect(network.regions?.[0]?.fills?.[0]?.color).toEqual({ r: 1, g: 0, b: 0, a: 1 })
expect(Object.isFrozen(network.vertices)).toBe(true)
expect(Object.isFrozen(network.vertices[0])).toBe(true)
expect(Object.isFrozen(network.segments[0]?.tangentStart)).toBe(true)
expect(Object.isFrozen(network.regions?.[0]?.loops[0])).toBe(true)
const tangentStart = { x: 2, y: 3 }
vector.vectorNetwork = {
vertices: [
{ x: 5, y: 6 },
{ x: 15, y: 16 }
],
segments: [{ start: 0, end: 1, tangentStart }]
}
tangentStart.x = 99
expect(vector.vectorNetwork.regions).toEqual([])
expect(vector.vectorNetwork.segments[0]?.tangentStart).toEqual({ x: 2, y: 3 })
expect(vector.vectorPaths[0]?.windingRule).toBe('NONE')
})
test('supports async network assignment and rejects Figma-invalid input', async () => {
const api = createAPI()
const vector = api.createVector()
await vector.setVectorNetworkAsync({
vertices: [
{ x: 5, y: 6 },
{ x: 15, y: 16 }
],
segments: [{ start: 0, end: 1 }]
})
expect(vector.vectorNetwork.vertices).toHaveLength(2)
vector.vectorPaths = [{ windingRule: 'NONZERO', data: 'M 0 0 L 10 10' }]
const openPathNetwork = vector.vectorNetwork
vector.vectorNetwork = openPathNetwork
expect(vector.vectorPaths).toEqual([{ windingRule: 'NONZERO', data: 'M0 0L10 10' }])
await expect(
vector.setVectorNetworkAsync({
vertices: [{ x: 0, y: 0 }],
segments: [{ start: 0, end: 2 }]
})
).rejects.toThrow('end index 2 out of range')
expect(() => {
vector.vectorNetwork = null as never
}).toThrow('network must be an object')
expect(() => {
vector.vectorNetwork = {
vertices: [{ x: 0, y: 0 }],
segments: [{ start: 0, end: 2 }]
}
}).toThrow('end index 2 out of range')
expect(() => {
vector.vectorNetwork = {
vertices: [{ x: 0, y: 0 }],
segments: [],
regions: [{ windingRule: 'NONZERO', loops: [[]] }]
}
}).toThrow('must contain at least one segment')
expect(() => {
vector.vectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 0, y: 10 }
],
segments: [
{ start: 0, end: 1 },
{ start: 1, end: 2 },
{ start: 2, end: 0 }
],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]], fills: 'red' as never }]
}
}).toThrow('fills must be an array')
expect(() => {
vector.vectorNetwork = {
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 0, y: 10 }
],
segments: [
{ start: 0, end: 1 },
{ start: 1, end: 2 },
{ start: 2, end: 0 }
],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]], fillStyleId: 42 as never }]
}
}).toThrow('fillStyleId must be a string')
expect(() => {
vector.vectorPaths = [{ windingRule: 1 as never, data: 'M 0 0 L 10 0' }]
}).toThrow('windingRule is invalid')
expect(() => {
vector.vectorPaths = [{ windingRule: 'NONZERO', data: 'm 0 0 l 10 0' }]
}).toThrow('Unsupported path command')
expect(() => {
vector.vectorPaths = [{ windingRule: 'NONZERO', data: 'M 0 0 L' }]
}).toThrow('Invalid vector path')
})
test('matches Figma handle mirroring and mixed behavior', () => {
const api = createAPI()
const vector = api.createVector()
vector.handleMirroring = 'ANGLE'
expect(vector.handleMirroring).toBe('ANGLE')
vector.vectorNetwork = {
vertices: [
{ x: 0, y: 0, handleMirroring: 'ANGLE' },
{ x: 10, y: 0, handleMirroring: 'NONE' }
],
segments: [{ start: 0, end: 1 }]
}
expect(vector.handleMirroring).toBe(api.mixed)
vector.handleMirroring = 'ANGLE_AND_LENGTH'
expect(vector.handleMirroring).toBe('ANGLE_AND_LENGTH')
expect(vector.vectorNetwork.vertices.map((vertex) => vertex.handleMirroring)).toEqual([
'ANGLE_AND_LENGTH',
'ANGLE_AND_LENGTH'
])
})
})

View file

@ -108,6 +108,25 @@ describe('vectorNetworkToSVGPaths()', () => {
expect(paths[0]).toContain('C')
})
test('traces arbitrarily ordered open segments as one path', () => {
const paths = vectorNetworkToSVGPaths({
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 20, y: 0 },
{ x: 30, y: 0 }
],
segments: [
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: []
})
expect(paths).toEqual(['M0 0L10 0L20 0L30 0'])
})
test('region with loop', () => {
const paths = vectorNetworkToSVGPaths({
vertices: [
@ -127,6 +146,60 @@ describe('vectorNetworkToSVGPaths()', () => {
expect(paths[0]).toContain('Z')
})
test('traces non-directional region segments continuously', () => {
const paths = vectorNetworkToSVGPaths({
vertices: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 50, y: 100 }
],
segments: [
{ start: 1, end: 0, 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: 0, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]] }]
})
expect(paths).toEqual(['M0 0L100 0L50 100L0 0Z'])
})
test('starts a new subpath for disconnected region segments', () => {
const paths = vectorNetworkToSVGPaths({
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 20, y: 0 },
{ x: 30, y: 0 }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [{ windingRule: 'NONZERO', loops: [[0, 1]] }]
})
expect(paths).toEqual(['M0 0L10 0M20 0L30 0'])
})
test('recognizes closed unfilled segment chains', () => {
const paths = vectorNetworkToSVGPaths({
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 0, y: 10 }
],
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: []
})
expect(paths).toEqual(['M10 0L0 10L0 0L10 0Z'])
})
test('empty network', () => {
const paths = vectorNetworkToSVGPaths({ vertices: [], segments: [], regions: [] })
expect(paths).toHaveLength(0)

View file

@ -1,6 +1,10 @@
import { describe, test, expect } from 'bun:test'
import { validateVectorNetwork, type VectorNetwork } from '@open-pencil/core'
import {
normalizeVectorNetwork,
validateVectorNetwork,
type VectorNetwork
} from '@open-pencil/core'
describe('validateVectorNetwork', () => {
test('valid network returns no errors', () => {
@ -59,17 +63,51 @@ describe('validateVectorNetwork', () => {
expect(validateVectorNetwork('not a network')).toEqual(['network must be an object'])
})
test('rejects invalid region topology', () => {
const errors = validateVectorNetwork({
test('accepts and normalizes omitted regions like the Figma Plugin API', () => {
const network = {
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 }
],
segments: [{ start: 0, end: 1 }],
regions: [{ windingRule: 'INVALID', loops: [[1]] }]
segments: [{ start: 0, end: 1 }]
}
expect(validateVectorNetwork(network)).toEqual([])
expect(normalizeVectorNetwork(network).regions).toEqual([])
})
test('rejects a non-array regions value', () => {
const errors = validateVectorNetwork({ vertices: [], segments: [], regions: {} } as never)
expect(errors).toContain('regions must be an array when provided')
})
test('rejects invalid region topology', () => {
const errors = validateVectorNetwork({
vertices: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 20, y: 0 },
{ x: 30, y: 0 }
],
segments: [
{ start: 0, end: 1 },
{ start: 2, end: 3 },
{ start: 1, end: 2 }
],
regions: [
{ windingRule: 'INVALID', loops: [[3]] },
{ windingRule: 'NONZERO', loops: [] },
{ windingRule: 'NONZERO', loops: [[]] },
{ windingRule: 'NONZERO', loops: [[0, 0]] },
{ windingRule: 'NONZERO', loops: [[0, 1]] }
]
})
expect(errors).toContain('region[0]: windingRule must be NONZERO or EVENODD')
expect(errors).toContain('region[0].loop[0]: segment index 1 out of range')
expect(errors).toContain('region[0].loop[0]: segment index 3 out of range')
expect(errors).toContain('region[1]: loops must contain at least one loop')
expect(errors).toContain('region[2].loop[0] must contain at least one segment')
expect(errors).toContain('region[3].loop[0] must not repeat segments')
expect(errors).toContain('region[4].loop[0] segments must form a continuous chain')
})
})