fix(tools): create vectors from SVG paths (#440)
- Accept SVG path data with tight curve-aware node bounds - Validate complete VectorNetwork topology while preserving JSON input - Reject empty and malformed paths before creating blank layers Co-authored-by: Danila Poyarkov <dev@dannote.net>
This commit is contained in:
parent
80a3cd8bd3
commit
db15d8d583
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- 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)
|
||||
- Help AI agents discover every shape supported by `create_shape`. (#448)
|
||||
|
|
|
|||
|
|
@ -1,40 +1,90 @@
|
|||
import { safeDestr } from 'destr'
|
||||
|
||||
import { normalizeVectorNetwork, validateVectorNetwork } from '@open-pencil/scene-graph'
|
||||
import {
|
||||
normalizeVectorNetwork,
|
||||
transformVectorNetwork,
|
||||
validateVectorNetwork
|
||||
} from '@open-pencil/scene-graph'
|
||||
import type { VectorNetwork } from '@open-pencil/scene-graph'
|
||||
import { parseSVGPath } from '@open-pencil/scene-graph/parse-path'
|
||||
|
||||
import { parseColor } from '#core/color'
|
||||
import { defineTool, nodeSummary } from '#core/tools/schema'
|
||||
import { computeAccurateBounds } from '#core/vector/curve-math'
|
||||
|
||||
interface ParsedVectorPath {
|
||||
network: VectorNetwork
|
||||
size: { width: number; height: number } | null
|
||||
}
|
||||
|
||||
type VectorPathResult = ParsedVectorPath | { error: string }
|
||||
|
||||
function parseSVGVectorPath(path: string): VectorPathResult {
|
||||
const network = parseSVGPath(path)
|
||||
if (network.segments.length === 0) {
|
||||
return { error: 'SVG path data must contain at least one drawable segment' }
|
||||
}
|
||||
|
||||
const bounds = computeAccurateBounds(network)
|
||||
return {
|
||||
network: transformVectorNetwork([1, 0, -bounds.x, 0, 1, -bounds.y, 0, 0, 1], network),
|
||||
size: { width: bounds.width, height: bounds.height }
|
||||
}
|
||||
}
|
||||
|
||||
function parseVectorNetworkJSON(path: string): VectorPathResult {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = safeDestr(path)
|
||||
} catch {
|
||||
return { error: 'Invalid VectorNetwork JSON' }
|
||||
}
|
||||
|
||||
const errors = validateVectorNetwork(parsed)
|
||||
if (errors.length > 0) return { error: `Invalid VectorNetwork: ${errors.join('; ')}` }
|
||||
|
||||
return { network: normalizeVectorNetwork(parsed as VectorNetwork), size: null }
|
||||
}
|
||||
|
||||
function parseVectorPath(path: string): VectorPathResult {
|
||||
const trimmed = path.trim()
|
||||
if (/^[Mm]/.test(trimmed)) return parseSVGVectorPath(trimmed)
|
||||
return parseVectorNetworkJSON(trimmed)
|
||||
}
|
||||
|
||||
export const createVector = defineTool({
|
||||
name: 'create_vector',
|
||||
mutates: true,
|
||||
description: 'Create a vector node with optional path data.',
|
||||
description: 'Create a vector node from SVG path data or a VectorNetwork.',
|
||||
params: {
|
||||
x: { type: 'number', description: 'X position', required: true },
|
||||
y: { type: 'number', description: 'Y position', required: true },
|
||||
name: { type: 'string', description: 'Node name' },
|
||||
path: { type: 'string', description: 'VectorNetwork JSON' },
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
'SVG path data (preferred, e.g. "M0 0 L100 0 L50 80 Z") or VectorNetwork JSON, e.g. {"vertices":[{"x":0,"y":0},{"x":10,"y":0}],"segments":[{"start":0,"end":1}],"regions":[]}'
|
||||
},
|
||||
fill: { type: 'color', description: 'Fill color (hex)' },
|
||||
stroke: { type: 'color', description: 'Stroke color (hex)' },
|
||||
stroke_weight: { type: 'number', description: 'Stroke weight' },
|
||||
parent_id: { type: 'string', description: 'Parent node ID' }
|
||||
},
|
||||
execute: (figma, args) => {
|
||||
let parsedPath: ParsedVectorPath | null = null
|
||||
if (args.path !== undefined) {
|
||||
const parsed = parseVectorPath(args.path)
|
||||
if ('error' in parsed) return parsed
|
||||
parsedPath = parsed
|
||||
}
|
||||
|
||||
const node = figma.createVector()
|
||||
node.x = args.x
|
||||
node.y = args.y
|
||||
if (args.name) node.name = args.name
|
||||
if (args.path) {
|
||||
let parsed: VectorNetwork
|
||||
try {
|
||||
parsed = safeDestr<VectorNetwork>(args.path)
|
||||
} 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 (parsedPath) {
|
||||
figma.graph.updateNode(node.id, { vectorNetwork: parsedPath.network })
|
||||
if (parsedPath.size) node.resize(parsedPath.size.width, parsedPath.size.height)
|
||||
}
|
||||
if (args.fill) {
|
||||
node.fills = [{ type: 'SOLID', color: parseColor(args.fill), opacity: 1, visible: true }]
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ export function parseSVGPath(d: string, windingRule: WindingRule = 'NONZERO'): V
|
|||
addSegment(x1, y1, x2, y2, cp1x, cp1y, cp2x, cp2y)
|
||||
}
|
||||
|
||||
const normalized = svgpath(d).abs().unshort().unarc()
|
||||
const parsed = svgpath(d)
|
||||
if ('err' in parsed && parsed.err) return { vertices, segments, regions: [] }
|
||||
const normalized = parsed.abs().unshort().unarc()
|
||||
|
||||
normalized.iterate((seg) => {
|
||||
const cmd = seg[0]
|
||||
|
|
|
|||
|
|
@ -96,38 +96,118 @@ 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[] {
|
||||
export function validateVectorNetwork(value: unknown): string[] {
|
||||
if (!isRecord(value)) return ['network must be an object']
|
||||
if (!Array.isArray(value.vertices)) return ['vertices must be an array']
|
||||
if (!Array.isArray(value.segments)) return ['segments must be an array']
|
||||
|
||||
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`)
|
||||
}
|
||||
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')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function validateVertices(vertices: unknown[], errors: string[]): void {
|
||||
for (let index = 0; index < vertices.length; index++) {
|
||||
if (!isFiniteVector(vertices[index])) {
|
||||
errors.push(`vertex[${index}]: x and y must be finite numbers`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSegments(segments: unknown[], vertexCount: number, errors: string[]): void {
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index]
|
||||
if (!isRecord(segment)) {
|
||||
errors.push(`segment[${index}] must be an object`)
|
||||
continue
|
||||
}
|
||||
if (!isInteger(segment.start) || !isInteger(segment.end)) {
|
||||
errors.push(`segment[${index}]: start and end must be integers`)
|
||||
continue
|
||||
}
|
||||
if (segment.start < 0 || segment.start >= vertexCount) {
|
||||
errors.push(`segment[${index}]: start index ${segment.start} out of range`)
|
||||
}
|
||||
if (segment.end < 0 || segment.end >= vertexCount) {
|
||||
errors.push(`segment[${index}]: end index ${segment.end} out of range`)
|
||||
}
|
||||
validateSegmentTangents(segment, index, errors)
|
||||
}
|
||||
}
|
||||
|
||||
function validateSegmentTangents(
|
||||
segment: Record<string, unknown>,
|
||||
index: number,
|
||||
errors: string[]
|
||||
): void {
|
||||
for (const tangentKey of ['tangentStart', 'tangentEnd'] as const) {
|
||||
const tangent = segment[tangentKey]
|
||||
if (tangent !== undefined && !isFiniteVector(tangent)) {
|
||||
errors.push(`segment[${index}]: ${tangentKey} must contain finite x and y numbers`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRegions(regions: unknown[], segmentCount: number, errors: string[]): void {
|
||||
for (let regionIndex = 0; regionIndex < regions.length; regionIndex++) {
|
||||
const region = regions[regionIndex]
|
||||
if (!isRecord(region) || !Array.isArray(region.loops)) {
|
||||
errors.push(`region[${regionIndex}]: loops must be an array`)
|
||||
continue
|
||||
}
|
||||
if (region.windingRule !== 'NONZERO' && region.windingRule !== 'EVENODD') {
|
||||
errors.push(`region[${regionIndex}]: windingRule must be NONZERO or EVENODD`)
|
||||
}
|
||||
validateRegionLoops(region.loops, regionIndex, segmentCount, errors)
|
||||
}
|
||||
}
|
||||
|
||||
function validateRegionLoops(
|
||||
loops: unknown[],
|
||||
regionIndex: number,
|
||||
segmentCount: number,
|
||||
errors: string[]
|
||||
): void {
|
||||
for (let loopIndex = 0; loopIndex < loops.length; loopIndex++) {
|
||||
const loop = loops[loopIndex]
|
||||
if (!Array.isArray(loop)) {
|
||||
errors.push(`region[${regionIndex}].loop[${loopIndex}] must be an array`)
|
||||
continue
|
||||
}
|
||||
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`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteVector(value: unknown): value is Vector {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.x === 'number' &&
|
||||
Number.isFinite(value.x) &&
|
||||
typeof value.y === 'number' &&
|
||||
Number.isFinite(value.y)
|
||||
)
|
||||
}
|
||||
|
||||
function isInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure every segment has tangentStart/tangentEnd.
|
||||
* Missing tangents default to {x:0, y:0} (straight line segments).
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ describe('parseSVGPath', () => {
|
|||
expect(vn.segments.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
test('malformed input does not preserve partial geometry', () => {
|
||||
expect(parseSVGPath('M0 0 L')).toEqual({ vertices: [], segments: [], regions: [] })
|
||||
})
|
||||
|
||||
test('round-trip: parse → export produces valid SVG paths', () => {
|
||||
const d = 'M0 0 L100 0 L100 100 L0 100 Z'
|
||||
const vn = parseSVGPath(d)
|
||||
|
|
|
|||
|
|
@ -116,3 +116,91 @@ describe('render', () => {
|
|||
expect(result.textAlignHorizontal).toBe('CENTER')
|
||||
})
|
||||
})
|
||||
|
||||
describe('create_vector', () => {
|
||||
test('creates tightly bounded curved geometry from SVG path data', () => {
|
||||
const { figma, graph } = setupToolTest()
|
||||
const result = getTool('create_vector').execute(figma, {
|
||||
x: 100,
|
||||
y: 200,
|
||||
name: 'Curved outline',
|
||||
path: 'M10 20 C20 20 30 40 40 40 L10 40 Z'
|
||||
}) as ToolResult
|
||||
const node = expectDefined(
|
||||
graph.getNode(expectDefined(result.id, 'created vector id')),
|
||||
'created vector'
|
||||
)
|
||||
const network = expectDefined(node.vectorNetwork, 'SVG vector network')
|
||||
|
||||
expect({ x: node.x, y: node.y, width: node.width, height: node.height }).toEqual({
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 30,
|
||||
height: 20
|
||||
})
|
||||
expect(network.vertices).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 30, y: 20 },
|
||||
{ x: 0, y: 20 }
|
||||
])
|
||||
expect(network.segments[0]?.tangentStart).toEqual({ x: 10, y: 0 })
|
||||
expect(network.segments[0]?.tangentEnd).toEqual({ x: -10, y: 0 })
|
||||
expect(network.regions).toEqual([{ windingRule: 'NONZERO', loops: [[0, 1, 2]] }])
|
||||
})
|
||||
|
||||
test('preserves complete VectorNetwork JSON topology', () => {
|
||||
const { figma, graph } = setupToolTest()
|
||||
const result = getTool('create_vector').execute(figma, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
path: JSON.stringify({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 0, y: 10 }
|
||||
],
|
||||
segments: [
|
||||
{ start: 0, end: 1, tangentStart: { x: 1, y: 0 }, tangentEnd: { x: -1, y: 0 } },
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 2, end: 0 }
|
||||
],
|
||||
regions: [{ windingRule: 'EVENODD', loops: [[0, 1, 2]] }]
|
||||
})
|
||||
}) as ToolResult
|
||||
const node = expectDefined(
|
||||
graph.getNode(expectDefined(result.id, 'created vector id')),
|
||||
'created vector'
|
||||
)
|
||||
|
||||
expect(node.vectorNetwork).toEqual({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 0, y: 10 }
|
||||
],
|
||||
segments: [
|
||||
{
|
||||
start: 0,
|
||||
end: 1,
|
||||
tangentStart: { x: 1, y: 0 },
|
||||
tangentEnd: { x: -1, 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: [{ windingRule: 'EVENODD', loops: [[0, 1, 2]] }]
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects invalid supplied paths before creating a node', () => {
|
||||
const { figma, graph } = setupToolTest()
|
||||
const tool = getTool('create_vector')
|
||||
const before = graph.nodes.size
|
||||
|
||||
for (const path of ['', 'not a path', 'null', 'M0 0 L']) {
|
||||
const result = tool.execute(figma, { x: 0, y: 0, name: 'Ghost', path }) as ToolResult
|
||||
expect(result.error).toBeDefined()
|
||||
expect(graph.nodes.size).toBe(before)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -51,6 +51,25 @@ describe('validateVectorNetwork', () => {
|
|||
regions: []
|
||||
} as VectorNetwork
|
||||
const errors = validateVectorNetwork(network)
|
||||
expect(errors[0]).toContain('x and y must be numbers')
|
||||
expect(errors[0]).toContain('x and y must be finite numbers')
|
||||
})
|
||||
|
||||
test('rejects non-object input without throwing', () => {
|
||||
expect(validateVectorNetwork(null)).toEqual(['network must be an object'])
|
||||
expect(validateVectorNetwork('not a network')).toEqual(['network must be an object'])
|
||||
})
|
||||
|
||||
test('rejects invalid region topology', () => {
|
||||
const errors = validateVectorNetwork({
|
||||
vertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 }
|
||||
],
|
||||
segments: [{ start: 0, end: 1 }],
|
||||
regions: [{ windingRule: 'INVALID', loops: [[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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue