diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8cbec4129..5512b9889 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -27,6 +27,7 @@ export { generateId, cloneVectorNetwork, normalizeVectorNetwork, + validateVectorNetwork, type SceneNode, type NodeType, type Fill, diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index ab75fbcea..2ea633130 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -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) } diff --git a/packages/core/src/tools/create.ts b/packages/core/src/tools/create.ts index aeddbc9f5..b6b0aad3f 100644 --- a/packages/core/src/tools/create.ts +++ b/packages/core/src/tools/create.ts @@ -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 }] diff --git a/tests/engine/vector.test.ts b/tests/engine/vector.test.ts index 474d17db9..ba275522f 100644 --- a/tests/engine/vector.test.ts +++ b/tests/engine/vector.test.ts @@ -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({