fix(vector): validate VectorNetwork input in create_vector

create_vector now validates the path JSON before accepting it:
checks vertices have numeric coordinates and segment indices are
in range. Returns a clear error message instead of silently
creating a malformed node that crashes on save.

The scene graph also normalizes vectorNetwork on updateNode as
a safety net for other code paths.
This commit is contained in:
Danila Poyarkov 2026-04-22 14:35:25 +03:00
parent 3727fa19d0
commit ab1ff69f99
4 changed files with 98 additions and 3 deletions

View file

@ -27,6 +27,7 @@ export {
generateId,
cloneVectorNetwork,
normalizeVectorNetwork,
validateVectorNetwork,
type SceneNode,
type NodeType,
type Fill,

View file

@ -78,6 +78,42 @@ export function cloneVectorNetwork(vn: VectorNetwork): VectorNetwork {
}
}
/**
* Validate a VectorNetwork structure, returning an array of error messages.
* Empty array means the network is valid.
*/
export function validateVectorNetwork(vn: VectorNetwork): string[] {
const errors: string[] = []
if (!Array.isArray(vn.vertices)) {
errors.push('vertices must be an array')
return errors
}
if (!Array.isArray(vn.segments)) {
errors.push('segments must be an array')
return errors
}
if (!Array.isArray(vn.regions)) errors.push('regions must be an array')
const vertexCount = vn.vertices.length
for (let i = 0; i < vn.vertices.length; i++) {
const v = vn.vertices[i]
if (typeof v.x !== 'number' || typeof v.y !== 'number') {
errors.push(`vertex[${i}]: x and y must be numbers`)
}
}
for (let i = 0; i < vn.segments.length; i++) {
const s = vn.segments[i]
if (typeof s.start !== 'number' || typeof s.end !== 'number') {
errors.push(`segment[${i}]: start and end must be numbers`)
} else {
if (s.start < 0 || s.start >= vertexCount)
errors.push(`segment[${i}]: start index ${s.start} out of range`)
if (s.end < 0 || s.end >= vertexCount)
errors.push(`segment[${i}]: end index ${s.end} out of range`)
}
}
return errors
}
/**
* Ensure every segment has tangentStart/tangentEnd.
* Missing tangents default to {x:0, y:0} (straight line segments).
@ -860,6 +896,9 @@ export class SceneGraph {
) {
node.textPicture = null
}
if (changes.vectorNetwork) {
changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) }
}
Object.assign(node, changes)
this.emitter.emit('node:updated', id, changes)
}

View file

@ -1,7 +1,7 @@
import { parseColor } from '../color'
import { fetchIcons, searchIconsBatch } from '../icons'
import { createIconFromPaths } from '../icons/render'
import { normalizeVectorNetwork } from '../scene-graph'
import { normalizeVectorNetwork, validateVectorNetwork } from '../scene-graph'
import { defineTool, nodeSummary } from './schema'
import type { FigmaNodeProxy } from '../figma-api'
@ -170,8 +170,15 @@ export const createVector = defineTool({
node.y = args.y
if (args.name) node.name = args.name
if (args.path) {
const network = normalizeVectorNetwork(JSON.parse(args.path) as VectorNetwork)
figma.graph.updateNode(node.id, { vectorNetwork: network })
let parsed: VectorNetwork
try {
parsed = JSON.parse(args.path) as VectorNetwork
} catch {
return { error: 'Invalid JSON in path parameter' }
}
const errors = validateVectorNetwork(parsed)
if (errors.length > 0) return { error: `Invalid VectorNetwork: ${errors.join('; ')}` }
figma.graph.updateNode(node.id, { vectorNetwork: normalizeVectorNetwork(parsed) })
}
if (args.fill) {
node.fills = [{ type: 'SOLID', color: parseColor(args.fill), opacity: 1, visible: true }]

View file

@ -5,6 +5,7 @@ import {
decodeVectorNetworkBlob,
computeVectorBounds,
normalizeVectorNetwork,
validateVectorNetwork,
type VectorNetwork,
} from '@open-pencil/core'
@ -191,6 +192,53 @@ describe('normalizeVectorNetwork', () => {
})
})
describe('validateVectorNetwork', () => {
test('valid network returns no errors', () => {
const network: VectorNetwork = {
vertices: [{ x: 0, y: 0 }, { x: 10, y: 10 }],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
}
expect(validateVectorNetwork(network)).toEqual([])
})
test('segments without tangents are valid (normalize handles them)', () => {
const network = {
vertices: [{ x: 0, y: 0 }, { x: 10, y: 10 }],
segments: [{ start: 0, end: 1 }],
regions: []
} as unknown as VectorNetwork
expect(validateVectorNetwork(network)).toEqual([])
})
test('rejects segment with out-of-range start index', () => {
const network = {
vertices: [{ x: 0, y: 0 }],
segments: [{ start: 0, end: 5 }],
regions: []
} as unknown as VectorNetwork
const errors = validateVectorNetwork(network)
expect(errors.length).toBe(1)
expect(errors[0]).toContain('end index 5 out of range')
})
test('rejects missing vertices array', () => {
const network = { segments: [], regions: [] } as unknown as VectorNetwork
const errors = validateVectorNetwork(network)
expect(errors[0]).toContain('vertices must be an array')
})
test('rejects vertex with non-number coordinates', () => {
const network = {
vertices: [{ x: 'a', y: 0 }],
segments: [],
regions: []
} as unknown as VectorNetwork
const errors = validateVectorNetwork(network)
expect(errors[0]).toContain('x and y must be numbers')
})
})
describe('computeVectorBounds', () => {
test('empty network', () => {
expect(computeVectorBounds({ vertices: [], segments: [], regions: [] })).toEqual({