diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd888aba..62e8f96a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/packages/core/src/figma-api/accessors/vector.ts b/packages/core/src/figma-api/accessors/vector.ts index 000c14acc..d3da69781 100644 --- a/packages/core/src/figma-api/accessors/vector.ts +++ b/packages/core/src/figma-api/accessors/vector.ts @@ -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> + readonly fills?: readonly Fill[] + readonly fillStyleId?: string +} + +export interface FigmaVectorNetwork { + readonly vertices: readonly VectorVertex[] + readonly segments: readonly (Omit & { + 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(['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).regions ?? [] + if (regions.length === 0) { + return vectorNetworkToSVGPaths({ ...network, regions }, null).map((data) => ({ + windingRule: 'NONE', + data + })) + } + + const paths: FigmaVectorPath[] = [] + const usedSegments = new Set() + 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).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 { + 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 } }) } diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index e3fe44900..4a311c92b 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -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 + declare handleMirroring: SceneNode['handleMirroring'] | typeof MIXED declare readonly explicitVariableModes: Readonly> declare readonly resolvedVariableModes: Readonly> @@ -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) diff --git a/packages/core/src/io/formats/svg/paths.ts b/packages/core/src/io/formats/svg/paths.ts index a2a0079df..64ef7f48a 100644 --- a/packages/core/src/io/formats/svg/paths.ts +++ b/packages/core/src/io/formats/svg/paths.ts @@ -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 + +function buildSegmentAdjacency(segments: VectorSegment[]): SegmentAdjacency { + const adjacency = new Map() + 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, + adjacency: SegmentAdjacency, + vertexIndex: number +): number | undefined { + return adjacency.get(vertexIndex)?.find((index) => remaining.has(index)) +} + +function extendSegmentChain( + chain: number[], + startVertex: number, + remaining: Set, + 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 { diff --git a/packages/scene-graph/src/node-defaults.ts b/packages/scene-graph/src/node-defaults.ts index f5764b967..f076213ee 100644 --- a/packages/scene-graph/src/node-defaults.ts +++ b/packages/scene-graph/src/node-defaults.ts @@ -90,6 +90,7 @@ export function createDefaultNode( layoutGrow: 0, layoutAlignSelf: 'AUTO', vectorNetwork: null, + handleMirroring: 'NONE', fillGeometry: [], strokeGeometry: [], arcData: null, diff --git a/packages/scene-graph/src/parse-path.ts b/packages/scene-graph/src/parse-path.ts index 87f3fb8f3..20c6f868b 100644 --- a/packages/scene-graph/src/parse-path.ts +++ b/packages/scene-graph/src/parse-path.ts @@ -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() + 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 } } } diff --git a/packages/scene-graph/src/types.ts b/packages/scene-graph/src/types.ts index ae401a327..464c57426 100644 --- a/packages/scene-graph/src/types.ts +++ b/packages/scene-graph/src/types.ts @@ -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[] diff --git a/packages/scene-graph/src/vector-network.ts b/packages/scene-graph/src/vector-network.ts index 5924160fc..c3ee18631 100644 --- a/packages/scene-graph/src/vector-network.ts +++ b/packages/scene-graph/src/vector-network.ts @@ -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> | 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> | 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> +): 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>, + 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 { return typeof value === 'object' && value !== null } +export type NormalizableVectorNetwork = Omit & { + 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).tangentStart ?? { ...ZERO }, tangentEnd: (s as Partial).tangentEnd ?? { ...ZERO } })), - regions: vn.regions + regions: vn.regions ?? [] } } diff --git a/tests/engine/figma/api/vector/paths.test.ts b/tests/engine/figma/api/vector/paths.test.ts index 317597674..2e34fd610 100644 --- a/tests/engine/figma/api/vector/paths.test.ts +++ b/tests/engine/figma/api/vector/paths.test.ts @@ -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' + ]) + }) }) diff --git a/tests/engine/io/svg/export/paths.test.ts b/tests/engine/io/svg/export/paths.test.ts index 81e316b6e..bec8ce3bc 100644 --- a/tests/engine/io/svg/export/paths.test.ts +++ b/tests/engine/io/svg/export/paths.test.ts @@ -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) diff --git a/tests/engine/vector/validate.test.ts b/tests/engine/vector/validate.test.ts index c443ade57..4f5f2de0c 100644 --- a/tests/engine/vector/validate.test.ts +++ b/tests/engine/vector/validate.test.ts @@ -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') }) })